阅读 2770

什么?仅靠H5标签就能实现收拉效果?

前言

最近做项目时碰到这么一个需求:

这有点类似于手风琴效果,但不一样的是很多手风琴效果是同一时间内只能有一个展开,而这个是各个部分独立的,你展不展开完全不会影响我的展开与否。其实这种效果简直再普遍不过了,网上随便一搜就出来一大堆。但不一样的是,我在接到这个需求的时候突然想起来很久以前看过张鑫旭大佬的一篇文章,模糊的记得那篇文章里说过有个什么很方便的 CSS 属性能够实现这一效果,不用像咱们平时实现的那些展开收起那样写很多的代码,于是就来到他的博客里面一顿搜,找了半天终于发现原来是我记错了,并不是什么 CSS3 属性,而是 HTML5 标签!

details

想要非常轻松的实现一个收拉效果,需要用到三个标签,分别是:<details><summary>以及随意

随意是什么意思?意思是什么标签都可以?

咱们先只写一个<details>标签来看看页面上会出现什么:

<details></details>
复制代码

运行结果:

可以看到非常有意思的一个现象:我们明明什么文字都没有写,但页面上却出现了详细信息这四个字,因为如果你在标签里没有写<summary>的话,浏览器会自动给你补上一个<summary>详细信息</summary>,那有人可能奇怪了,怎么补的是中文呢?那老外不写<summary>的话也会来一个<summary>详细信息</summary>?其实是这样:

现代浏览器经常偷偷获取用户隐私信息,包括但不仅限于用人工智能判断屏幕前的用户是中国人还是外国人,然后根据用户的母语来动态向<summary>标签里加入不同语言的'详细信息'这几个字。

开个玩笑,其实是根据你当前操作系统的语言来判断的,要是你把系统语言改成其它语言的话出现的就不再是'详细信息'这几个中文字符了。

那如果我们在<details>标签里写了<summary>呢?

<details>
  <summary>公众号:</summary>
</details>
复制代码

运行结果:

可以看到<summary>里面的文字就会在三角箭头旁边的标题位置展示出来,可是我们展开三角箭头发现里面什么内容也没有,那么内容写在哪呢?

只需写在<summary>的后面就可以了,那是不是还要写个固定标签呢?比如什么<describe>之类的,其实在<summary>之后无论写什么标签都可以,当然必须得是合法的 HTML 标签啊,比如我们写个<h1>标签来试试看:

<details>
  <summary>公众号:</summary>
  <h1>前端学不动</h1>
</details>
复制代码

运行结果:

再换个别的标签试试:

<details>
  <summary>公众号:</summary>
  <button>前端学不动</button>
</details>
复制代码

运行结果:

看!我们仅用了三个标签就完成了一个最简单的收拉效果!以前在网上看到类似的效果要么就是 getElementById 获取到 DOM 元素,然后添加 onclick 事件控制下方元素的 style 属性,要么就是纯 CSS 实现,写几个单选按钮配合兄弟选择器来控制后方元素的显隐,抑或是 CSS 与 JS 相结合来实现的,但仅靠 HTML 标签来实现这一效果还是非常清新脱俗的!并且十分简洁、非常节约代码量、也更加直观易于理解。

深入测试

既然<summary>标签后面写什么都行,那么可不可以写很多个标签呢?我们来测试一下:

<details>
  <summary>公众号:</summary>
  <button>前端学不动</button>
  <span>前端学不动</span>
  <h1>前端学不动</h1>
  <a href="#">前端学不动</a>
  <strong>前端学不动</strong>
</details>
复制代码

运行结果:

那展开收起那部分的内容只能放在<summary>标签之后吗?如果放它前面呢:

<details>
  <button>前端学不动</button>
  <span>前端学不动</span>
  <h1>前端学不动</h1>
  <a href="#">前端学不动</a>
  <strong>前端学不动</strong>
  <summary>公众号:</summary>
</details>
复制代码

运行结果:

效果居然一模一样,看来展开收起的那部分应该是在<details>标签内部的除<summary>标签之外的所有内容。那如果写两个<summary>标签呢:

<details>
  <button>前端学不动</button>
  <span>前端学不动</span>
  <h1>前端学不动</h1>
  <a href="#">前端学不动</a>
  <strong>前端学不动</strong>
  <summary>公众号:</summary>
  <summary>summary</summary>
</details>
复制代码

运行结果:

可以看到只有第一个出现的<summary>标签是真正的summary,后续出现的其他所有标签(包括其它的<summary>)都是展开收起的那部分。

既然所有标签都可以,那么也包括<details>咯?

<details>
  <summary>project</summary>
  <details>
    <summary>html</summary>
    index.html
  </details>
  <details>
    <summary>css</summary>
    reset.css
  </details>
  <details>
    <summary>js</summary>
    main.js
  </details>
</details>
复制代码

运行结果:

这玩意有点意思,利用这种嵌套写法可以轻松实现编辑器左侧的那些文件区的效果。

加入样式

虽然可以很轻松、甚至在不用写 CSS 代码的情况下就实现展开收起效果,但毕竟不写 CSS 只是实现了个最基础的乞丐版效果,很多人都不想要点击的时候出现的那个轮廓:

在谷歌浏览器和 Safari 浏览器下都会出现这个轮廓,火狐就没有这玩意,咱们只需要给<summary>标签设置 outline 属性就可以了,一般如果你的项目引入了抹平浏览器样式间差异的 reset.css 文件的话,就不用写这个 CSS 了,为了方便同时观看 HTML、CSS 和 JS,我们来用 Vue 的格式来写代码:

<template>
  <details>
    <summary>project</summary>
    <details>
      <summary>html</summary>
      index.html
    </details>
    <details>
      <summary>css</summary>
      reset.css
    </details>
    <details>
      <summary>js</summary>
      main.js
    </details>
  </details>
</template>

<style>
summary { outline: none }
</style>
复制代码

运行结果:

这样看起来就舒服多啦!但是还有个问题:那个三角箭头太傻大黑粗了,一般我们很少会用这样的箭头,而且我们也不一定非得让它在左边待着,那么怎么修改箭头的样式呢?

在谷歌浏览器以及 Safari 浏览器下我们需要用::-webkit-details-marker伪元素,在火狐浏览器下我们要用::-moz-list-bullet伪元素,比如我们想让它别那么傻大黑粗:

<template>
  <details>
    <summary>project</summary>
    <details>
      <summary>html</summary>
      index.html
    </details>
    <details>
      <summary>css</summary>
      reset.css
    </details>
    <details>
      <summary>js</summary>
      main.js
    </details>
  </details>
</template>

<style>
summary { outline: none }

/* 谷歌、Safari */
::-webkit-details-marker {
    transform: scale(.5);
    color: gray
}

/* 火狐 */
::-moz-list-bullet { color: gray }
</style>
复制代码

运行结果:

是不是没那么傻大黑粗了,不过有时我们不想要这个三角形的箭头,想要的是自己自定义的箭头,那么我们就需要先把这个默认的三角给隐藏掉:

<template>
  <details>
    <summary>project</summary>
    <details>
      <summary>html</summary>
      index.html
    </details>
    <details>
      <summary>css</summary>
      reset.css
    </details>
    <details>
      <summary>js</summary>
      main.js
    </details>
  </details>
</template>

<style>
summary { outline: none }

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }
</style>
复制代码

运行结果:

这回箭头没了,我们只需要在<summary>标签里写个箭头就好了,可以用::before::after伪元素,也可以直接在里面写个<img>标签,为了让大家能够直接复制代码到 Vue 环境里运行,在这里我们就不用图片了,直接手写<svg>

<template>
  <details>
    <summary>
      <svg width="16" height="7">
        <polyline points="0,0 8,7 16,0"/>
      </svg>
      project
    </summary>
    <details>
      <summary>
        <svg width="16" height="7">
          <polyline points="0,0 8,7 16,0"/>
        </svg>
        html
      </summary>
      index.html
    </details>
    <details>
      <summary>
        <svg width="16" height="7">
          <polyline points="0,0 8,7 16,0"/>
        </svg>
        css
      </summary>
      reset.css
    </details>
    <details>
      <summary>
        <svg width="16" height="7">
          <polyline points="0,0 8,7 16,0"/>
        </svg>
        js
      </summary>
      main.js
    </details>
  </details>
</template>

<style>
summary {
  position: relative;
  padding-left: 20px;
  outline: none
}

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }

svg {
  position: absolute;
  left: 0;
  top: 50%;
  fill: none;
  stroke: gray
}
</style>
复制代码

运行结果:

箭头是变成自定义的了,但是方向却不智能了,不能像原生箭头那样展开收起时会自动改变方向,但是<details>这个标签好就好在它在展开是会自动在标签里添加一个open属性:

我们可以利用它的这一特点,用属性选择器来让<svg>标签进行旋转:

<template>
  <details>
    <summary>
      <svg width="16" height="7">
        <polyline points="0,0 8,7 16,0"/>
      </svg>
      project
    </summary>
    <details>
      <summary>
        <svg width="16" height="7">
          <polyline points="0,0 8,7 16,0"/>
        </svg>
        html
      </summary>
      index.html
    </details>
    <details>
      <summary>
        <svg width="16" height="7">
          <polyline points="0,0 8,7 16,0"/>
        </svg>
        css
      </summary>
      reset.css
    </details>
    <details>
      <summary>
        <svg width="16" height="7">
          <polyline points="0,0 8,7 16,0"/>
        </svg>
        js
      </summary>
      main.js
    </details>
  </details>
</template>

<style>
summary {
  position: relative;
  padding-left: 20px;
  outline: none
}

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }

svg {
  position: absolute;
  left: 0;
  top: 50%;
  transform: rotate(180deg);
  transition: transform .2s;
  fill: none;
  stroke: gray
}

[open] > summary > svg { transform: none }
</style>
复制代码

运行结果:

用 JS 控制 open 属性

既然展开时会自动给<details>标签添加一个open属性,那如果我们用 JS 手动给<details>标签添加或删除open属性,<details>标签会随之展开收起吗?

比如我们用定时器,每隔1秒就自动展开一个,同时收起上一个已被展开过的标签:

<template>
  <details v-for="({title, content}, index) of list" :key="title" :open="openIndex === index">
    <summary>
      <svg width="16" height="7">
        <polyline points="0,0 8,7 16,0"/>
      </svg>
      {{ title }}
    </summary>
    {{ content }}
  </details>
</template>

<script>
import { defineComponent, ref, onBeforeUnmount } from 'vue'

export default defineComponent(() => {
  const list = [{
    title: 'html',
    content: 'index.html'
  }, {
    title: 'css',
    content: 'reset.css'
  }, {
    title: 'js',
    content: 'main.js'
  }]

  const openIndex = ref(-1)

  const interval = setInterval(() => openIndex.value === list.length
    ? openIndex.value = 0
    : openIndex.value++
  , 1000)

  onBeforeUnmount(() => clearInterval(interval))

  return { list, openIndex }
})
</script>

<style>
summary {
  position: relative;
  padding-left: 20px;
  outline: none
}

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }

svg {
  position: absolute;
  left: 0;
  top: 50%;
  transform: rotate(180deg);
  transition: transform .2s;
  fill: none;
  stroke: gray
}

[open] > summary > svg { transform: none }
</style>
复制代码

运行结果:

既然能靠控制open属性来控制元素的展开收起,那么手风琴效果也很好实现了:只需要保证在当前列表中仅有一个<details>标签有open属性,点击别的标签时就去掉另一个标签的open属性即可:

<template>
  <details
    v-for="({title, content}, index) of list"
    :key="title"
    :open="openIndex === index"
    @toggle="onChange($event, index)"
  >
    <summary>
      <svg width="16" height="7">
        <polyline points="0,0 8,7 16,0"/>
      </svg>
      {{ title }}
    </summary>
    {{ content }}
  </details>
</template>

<script>
import { defineComponent, ref } from 'vue'

export default defineComponent(() => {
  const list = [{
    title: 'html',
    content: 'index.html'
  }, {
    title: 'css',
    content: 'reset.css'
  }, {
    title: 'js',
    content: 'main.js'
  }]

  const openIndex = ref(-1)

  const onChange = ({ target }, i) => target.open && (openIndex.value = i)

  return { list, openIndex, onChange }
})
</script>

<style>
summary {
  position: relative;
  padding-left: 20px;
  outline: none
}

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }

svg {
  position: absolute;
  left: 0;
  top: 50%;
  transform: rotate(180deg);
  transition: transform .2s;
  fill: none;
  stroke: gray
}

[open] > summary > svg { transform: none }
</style>
复制代码

运行结果:

⚠️需要注意的是,在<details>标签展开收起时会触发一个 toggle 事件,和 click、mousemove 等事件用法一致,也会接收一个 event 对象的参数,event.target 是当前触发事件的 DOM,也就是<details>,它会有一个.open属性,值为 true 或 false,代表是否展开收起。

加入动画

那么接下来离一个理想的手风琴效果只差最后一步了:过渡动画

但过渡动画这里有坑,我们先来分析一下思路:在平时就给<details>标签里的内容区(除第一个出现的

标签以外的内容)写上:max-height: 0;
然后在 open 时用属性选择器 [open] 配合后代选择器来给内容区加上 max-height: xxx; 的代码,这样平时在收起时高度就是0,等出现 open 属性时就会慢慢过渡到我们定义的最大高度:

<template>
  <details
    v-for="({title, content}, index) of list"
    :key="title"
    :open="openIndex === index"
    @toggle="onChange($event, index)"
  >
    <summary>
      <svg width="16" height="7">
        <polyline points="0,0 8,7 16,0"/>
      </svg>
      {{ title }}
    </summary>
    <ul>
      <li v-for="doc of content" :key="doc">{{ doc }}</li>
    </ul>
  </details>
</template>

<script>
import { defineComponent, ref } from 'vue'

export default defineComponent(() => {
  const list = [{
    title: 'html',
    content: ['index.html', 'banner.html', 'login.html', '404.html']
  }, {
    title: 'css',
    content: ['reset.css', 'header.css', 'banner.css', 'footer.css']
  }, {
    title: 'js',
    content: ['index.js', 'main.js', 'javascript.js']
  }]

  const openIndex = ref(-1)

  const onChange = ({ target }, i) => target.open && (openIndex.value = i)

  return { list, openIndex, onChange }
})
</script>

<style>
summary {
  position: relative;
  padding-left: 20px;
  outline: none
}

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }

svg {
  position: absolute;
  left: 0;
  top: 50%;
  transform: rotate(180deg);
  transition: transform .2s;
  fill: none;
  stroke: gray
}

details > ul {
  max-height: 0;
  margin: 0;
  overflow: hidden;
}

[open] > summary > svg { transform: none }
[open] > ul { max-height: 120px }
</style>
复制代码

运行结果:

如果用谷歌浏览器打开的话居然看不到任何的过渡效果!但用火狐打开就有效果:

估计是浏览器的 bug,既然过渡动画(transition)在不同浏览器之间表现不一致,那关键帧动画(keyframes)呢?

<template>
  <details
    v-for="({title, content}, index) of list"
    :key="title"
    :open="openIndex === index"
    @toggle="onChange($event, index)"
  >
    <summary>
      <svg width="16" height="7">
        <polyline points="0,0 8,7 16,0"/>
      </svg>
      {{ title }}
    </summary>
    <ul>
      <li v-for="doc of content" :key="doc">{{ doc }}</li>
    </ul>
  </details>
</template>

<script>
import { defineComponent, ref } from 'vue'

export default defineComponent(() => {
  const list = [{
    title: 'html',
    content: ['index.html', 'banner.html', 'login.html', '404.html']
  }, {
    title: 'css',
    content: ['reset.css', 'header.css', 'banner.css', 'footer.css']
  }, {
    title: 'js',
    content: ['index.js', 'main.js', 'javascript.js']
  }]

  const openIndex = ref(-1)

  const onChange = ({ target }, i) => target.open && (openIndex.value = i)

  return { list, openIndex, onChange }
})
</script>

<style lang="scss">
summary {
  position: relative;
  padding-left: 20px;
  outline: none
}

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }

svg {
  position: absolute;
  left: 0;
  top: 50%;
  transform: rotate(180deg);
  transition: transform .2s;
  fill: none;
  stroke: gray
}

details > ul {
  max-height: 0;
  margin: 0;
  overflow: hidden;
}

[open] {
  > summary > svg { transform: none }
  > ul { animation: open .2s both }
}

@keyframes open {
  to { max-height: 120px }
}
</style>
复制代码

运行结果:

可以看到关键帧动画在各大浏览器的行为都是一致的,推荐大家使用关键帧动画。

收起动画

上面那种效果已经完全足够满足我们的日常开发需求了,但它仍然有一个小小的遗憾,那就是:收起的时候没有任何的动画效果。

这是因为<details>的行为是靠着 open 属性控制内容显示或隐藏,你可以简单的把它的隐藏理解为display: block;display: none;,虽然这么说可能并不准确,但却非常有助于我们理解<details>的行为:在展开时display: block;突然显示,既然显示了就可以有时间展示我们的展开动画。但在收起时display: none;是突然消失,根本没时间展示我们的收起动画。

那么怎么才能解决这个问题呢?答案就是更改 DOM 结构,我们把原本放在<details>里面那部分需要展开收起的内容元素移到<details>标签的外面去,但一定要在它的后一位,这样就可以方便我们用兄弟选择器配合属性选择器来控制外部元素的显隐了,在<details>标签有 open 属性时我们就让它的后面一个元素用动画展开,没有 open 属性时我们就让后一个元素用动画收起:

<template>
  <template v-for="({title, content}, index) of list" :key="title">
    <details
      :open="openIndex === index"
      @toggle="onChange($event, index)"
    >
      <summary>
        <svg width="16" height="7">
          <polyline points="0,0 8,7 16,0"/>
        </svg>
        {{ title }}
      </summary>
    </details>
    <ul>
      <li v-for="doc of content" :key="doc">{{ doc }}</li>
    </ul>
  </template>
</template>

<script>
import { defineComponent, ref } from 'vue'

export default defineComponent(() => {
  const list = [{
    title: 'html',
    content: ['index.html', 'banner.html', 'login.html', '404.html']
  }, {
    title: 'css',
    content: ['reset.css', 'header.css', 'banner.css', 'footer.css']
  }, {
    title: 'js',
    content: ['index.js', 'main.js', 'javascript.js']
  }]

  const openIndex = ref(-1)

  const onChange = ({ target }, i) => target.open && (openIndex.value = i)

  return { list, openIndex, onChange }
})
</script>

<style lang="scss">
summary {
  position: relative;
  padding-left: 20px;
  outline: none
}

/* 谷歌、Safari */
::-webkit-details-marker { display: none }

/* 火狐 */
::-moz-list-bullet { font-size: 0 }

svg {
  position: absolute;
  left: 0;
  top: 50%;
  transform: rotate(180deg);
  transition: transform .2s;
  fill: none;
  stroke: gray
}

ul {
  max-height: 0;
  margin: 0;
  transition: max-height .2s;
  overflow: hidden
}

[open] {
  > summary > svg { transform: none }
  + ul { max-height: 120px }
}
</style>
复制代码

运行结果:

结语

如果你的项目不需要这些花里胡哨的动画效果,完全可以只靠 H5 标签去实现,根本不必再去关心展开收起的逻辑了,只需要写一些样式代码就可以了,比如写成暗黑模式:

你的 CSS 只需要专注于暗黑模式本身就够了,是不是很省心呢?

同时这个收拉效果也并不仅仅只适用于手风琴,很多地方都可以用到它,比如这种:

但唯一比较遗憾的事就是这个标签不支持 IE:

不过好在别的浏览器支持的都不错,如果你的项目不需要兼容 IE 的话就请尽情的享受<details>标签所带来的便利吧!

本文首发于公众号:《前端学不动》

往期精彩文章

文章分类
前端
文章标签