移动端导航折叠面板

折叠面板Collapse

把长内容按主题收起来,让用户先看到标题、按需展开某一段

数据展示 规格 25 · 折叠面板 Collapse 6 端实现 触摸优先

引入(H5 原生;其余 5 端见「快速开始」)
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">

<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/mobile-collapse.css">

<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->

演示

每个演示都是真实渲染:预览帧加载 frameworks-mobile/Collapse.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。

01 组件类型

基础用法accordion=false

点标题行展开:整行热区不小于 52px,箭头转 90 度,aria-expanded 同步变化。

查看代码(演示页原文 · 17 行)
frameworks-mobile/Collapse.html · basic
<section class="demo-block" data-demo="basic">
  <p class="demo-label">基础用法(点标题行展开:整行热区 ≥52px,箭头转 90°,aria-expanded 同步)</p>
  <div class="demo-box">
    <div class="kole-m-collapse" data-assert="collapse-basic">
      <div class="kole-m-collapse__item" id="col-basic">
        <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="col-basic-panel"
                data-behavior="click-sets-attr:#col-basic-header|aria-expanded|true" id="col-basic-header">
          <span class="kole-m-collapse__title">配送与运费说明</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="col-basic-panel" hidden>
          <p class="kole-m-collapse__content">单笔满 99 元免运费;偏远地区按实际重量计费,下单页会先给出预估金额。</p>
        </div>
      </div>
    </div>
  </div>
</section>
多面板同时展开accordion=false

accordion=false 时各面板各自开合,互不影响。

查看代码(演示页原文 · 27 行)
frameworks-mobile/Collapse.html · multi
<section class="demo-block" data-demo="multi">
  <p class="demo-label">多面板同时展开(accordion=false:点标题行各自开合,互不影响)</p>
  <div class="demo-box">
    <div class="kole-m-collapse" data-assert="collapse-multi">
      <div class="kole-m-collapse__item is-expanded" id="col-multi-1">
        <button class="kole-m-collapse__header" type="button" aria-expanded="true" aria-controls="col-multi-1-panel"
                data-behavior="click-toggles-class:#col-multi-1|is-expanded">
          <span class="kole-m-collapse__title">已展开的面板</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="col-multi-1-panel">
          <p class="kole-m-collapse__content">再次点击标题行即可收起;同时打开第二个面板不会影响这一项。</p>
        </div>
      </div>
      <div class="kole-m-collapse__item" id="col-multi-2">
        <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="col-multi-2-panel"
                data-behavior="click-toggles-class:#col-multi-2|is-expanded">
          <span class="kole-m-collapse__title">收起的面板</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="col-multi-2-panel" hidden>
          <p class="kole-m-collapse__content">移动端屏幕窄,展开后内容会顶走上下文 —— 主题多时优先用手风琴。</p>
        </div>
      </div>
    </div>
  </div>
</section>
手风琴accordion=true

accordion=true 时展开新面板会自动收起当前展开项,适合主题多、屏幕窄的场合。

查看代码(演示页原文 · 26 行)
frameworks-mobile/Collapse.html · accordion
<section class="demo-block" data-demo="accordion">
  <p class="demo-label">手风琴(accordion=true:展开新面板会自动收起当前展开项)</p>
  <div class="demo-box">
    <div class="kole-m-collapse kole-m-collapse--accordion" data-accordion="true" data-assert="collapse-accordion">
      <div class="kole-m-collapse__item is-expanded" id="acc-1">
        <button class="kole-m-collapse__header" type="button" aria-expanded="true" aria-controls="acc-1-panel">
          <span class="kole-m-collapse__title">支付方式</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="acc-1-panel">
          <p class="kole-m-collapse__content">支持微信支付、支付宝与对公转账;对公转账需 1~2 个工作日到账。</p>
        </div>
      </div>
      <div class="kole-m-collapse__item" id="acc-2">
        <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="acc-2-panel" id="acc-2-header"
                data-behavior="click-sets-attr:#acc-2-header|aria-expanded|true">
          <span class="kole-m-collapse__title">发票与报销</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="acc-2-panel" hidden>
          <p class="kole-m-collapse__content">电子发票在订单完成后 2 小时内开出,可在订单详情页重新下载。</p>
        </div>
      </div>
    </div>
  </div>
</section>

02 组件状态

边框两态bordered=true|false

bordered=true 有外框与面板间分隔线;false 时靠留白分隔。

查看代码(演示页原文 · 25 行)
frameworks-mobile/Collapse.html · bordered
<section class="demo-block" data-demo="bordered">
  <p class="demo-label">边框两态(bordered=true 有外框与分隔线 / false 靠留白分隔)</p>
  <div class="demo-pad" data-assert="collapse-bordered">
    <div class="kole-m-collapse kole-m-collapse--bordered">
      <div class="kole-m-collapse__item is-expanded" id="brd-1">
        <button class="kole-m-collapse__header" type="button" aria-expanded="true" aria-controls="brd-1-panel">
          <span class="kole-m-collapse__title">带边框(bordered=true)</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="brd-1-panel">
          <p class="kole-m-collapse__content">面板之间有 1px 分隔线,组外有圆角边框,适合独立卡片。</p>
        </div>
      </div>
      <div class="kole-m-collapse__item" id="brd-2">
        <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="brd-2-panel">
          <span class="kole-m-collapse__title">同组第二个面板</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="brd-2-panel" hidden>
          <p class="kole-m-collapse__content">分隔线只在面板之间出现,首尾不画。</p>
        </div>
      </div>
    </div>
  </div>
</section>
禁用面板状态 disabled

状态 disabled:标题行置灰、不响应点击,aria-disabled 已置位且不可聚焦。

查看代码(演示页原文 · 17 行)
frameworks-mobile/Collapse.html · disabled
<section class="demo-block" data-demo="disabled">
  <p class="demo-label">禁用面板(标题行置灰、不响应点击,aria-disabled 已置位)</p>
  <div class="demo-box">
    <div class="kole-m-collapse" data-assert="collapse-disabled">
      <div class="kole-m-collapse__item is-disabled" id="col-disabled">
        <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="col-disabled-panel"
                aria-disabled="true" disabled>
          <span class="kole-m-collapse__title">企业协议条款(需管理员权限)</span>
          <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
        </button>
        <div class="kole-m-collapse__panel" id="col-disabled-panel" hidden>
          <p class="kole-m-collapse__content">无权限时内容不可展开。</p>
        </div>
      </div>
    </div>
  </div>
</section>

API

props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs

Props

名称类型默认值说明必传
itemsArray[]数据驱动的面板数组,每项含 key / title / content / disabled(规格 §25.2)N
accordionbooleanfalse变体 accordion:手风琴模式,展开新面板时收起当前展开项(规格 §25.3)N
borderedbooleantrue变体 bordered:面板之间有分隔线与外框(规格 §25.3)N
valueArray[]受控展开项:面板 key 的数组,点击后由 change 事件回传(规格 §25.5)N

「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。

事件

名称参数说明
change(key, expanded)面板标题行被点击后触发,回传该面板 key 与展开态布尔值(规格 §25.5)

插槽

名称说明
default追加在数据驱动面板之后的自定义面板(规格 §25.2 item)

CSS 变量

组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。

名称默认值说明
--kole-m-collapse-header-height52px标题行高度(≥ 44px 触控最小边长)
--kole-m-collapse-duration180ms箭头旋转时长

何时使用

  • 把长内容按主题收起来,让用户先看到标题、按需展开某一段
  • 移动端屏幕窄,展开后内容会顶走上下文,因此一次只展开一个(手风琴)是默认推荐形态
  • 展开态必须明确到不靠颜色也能看出(箭头方向 + aria-expanded)
  • 标题行整行都是热区,高度不小于 44px
  • 手风琴模式下展开新面板会收起当前展开项;accordion=false 时互不影响

交互与触控

  • 标题行整行都是热区,高度不小于 44px
  • 视觉箭头转 90 度(180ms 过渡),展开时 aria-expanded 同步为 true
  • 手风琴模式下展开新面板会收起当前展开项;accordion=false 时互不影响
  • 内容区不做高度动画,直接切换 hidden(省电,读屏也不会读到中间态);减少动态偏好下箭头同样瞬时切换

无障碍

  • 标题行用原生 button 并带 aria-expanded / aria-controls
  • 内容区与标题用 id / aria-controls 建立关联,收起时用 hidden 属性隐藏(而不是只靠 CSS 高度)
  • 禁用项置 aria-disabled="true" 且不可聚焦

从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。

组件何时用它而不是本组件
列表List行点击后跳转或选中用列表;就地展开内容用折叠面板
弹出层Popup内容需要遮罩聚焦时用弹出层;内容属于当前上下文的补充说明时用折叠面板
对话框Dialog需要用户先决策再继续时用对话框;只是看说明用折叠面板

规格未定 / 禁止发明

类别条目
禁止发明展开动画的高度换算公式(内容高度由浏览器决定)
禁止发明嵌套折叠面板的层级样式
规格未定默认是否展开第一项
规格未定标题行右侧是否允许放额外操作

结构(anatomy)

字段说明
collapse根元素,一组面板的容器
item单个面板,含标题行与内容区
header标题行,整行可点,高度不小于 44px
arrow标题行右侧箭头,展开时旋转 90 度
panel内容区,展开时可见(收起时高度为 0 或 hidden)
content内容区内的正文节点

变体维度与类名映射

类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
accordionfalse / true
false (由数据驱动,无专属类)
true .kole-m-collapse--accordion
borderedtrue / false
true .kole-m-collapse--bordered
false (由数据驱动,无专属类)

代表变体

变体标签
accordion=false · bordered=true多面板带边框(默认:各自开合互不影响)
accordion=true · bordered=true手风琴(同时只展开一个,移动端推荐形态)
accordion=false · bordered=false无边框面板(靠留白分隔,适合嵌在卡片内)

用到的令牌

构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。

--kole-m-font-size-body --kole-m-font-size-label --kole-m-gutter --kole-color-border --kole-color-brand --kole-color-card-bg --kole-color-focus-ring --kole-color-table-header-bg --kole-color-text-body --kole-color-text-disabled --kole-color-text-placeholder --kole-color-text-secondary --kole-color-text-title --kole-ease-standard --kole-font-family --kole-radius-large --kole-space-12 --kole-space-16 --kole-m-collapse-duration --kole-m-collapse-header-height

6 端源码

同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。

frameworks-mobile/Collapse.css · 纯样式(CSS) · 105 行
frameworks-mobile/Collapse.css
/* Kole UI Mobile · Collapse 样式 — 对齐移动端规格 §25
   折叠面板:标题行整行可点(≥44px),展开态用 is-expanded + aria-expanded 双写;
   箭头转 90°(180ms),内容区直接切 hidden(不做高度动画,省电且读屏不读中间态)。 */

.kole-m-collapse {
  --kole-m-collapse-header-height: 52px;  /* 标题行高度(≥ 44px 触控最小边长) */
  --kole-m-collapse-duration: 180ms;      /* 箭头旋转时长 */
  box-sizing: border-box;
  width: 100%;
  background: var(--kole-color-card-bg);
  font-family: var(--kole-font-family);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-body);
  line-height: 1.4;
}

/* 变体 bordered=true:外框 + 面板之间的分隔线 */
.kole-m-collapse--bordered {
  border: 1px solid var(--kole-color-border);
  border-radius: var(--kole-radius-large);
  overflow: hidden;
}

.kole-m-collapse--bordered .kole-m-collapse__item + .kole-m-collapse__item {
  border-top: 1px solid var(--kole-color-border);
}

/* 面板:标题行 + 内容区 */
.kole-m-collapse__item { display: block; }

/* 变体 accordion=true:手风琴(同时只展开一个)。
   展开逻辑在宿主/框架端实现(组件不阻止其它面板被程序化打开),
   这里只把该模式标出来,便于样式与业务侧选择器挂钩。 */
.kole-m-collapse--accordion .kole-m-collapse__item.is-expanded .kole-m-collapse__header {
  color: var(--kole-color-brand);
}

/* 标题行:整行都是热区(原生 button,键盘可达) */
.kole-m-collapse__header {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--kole-space-12);
  width: 100%;
  min-height: var(--kole-m-collapse-header-height);
  padding: var(--kole-space-12) var(--kole-m-gutter);
  margin: 0;
  border: 0;
  background: var(--kole-color-card-bg);
  color: var(--kole-color-text-title);
  font-family: inherit;
  font-size: inherit;
  line-height: 1.4;
  text-align: start;
  cursor: pointer;
  touch-action: manipulation;
}

.kole-m-collapse__header:active { background: var(--kole-color-table-header-bg); }

.kole-m-collapse__title {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 箭头:收起时指向右,展开时转 90° 指向下 */
.kole-m-collapse__arrow {
  flex: 0 0 auto;
  display: inline-flex;
  color: var(--kole-color-text-placeholder);
  transition: transform var(--kole-m-collapse-duration) var(--kole-ease-standard);
}

.kole-m-collapse__item.is-expanded .kole-m-collapse__arrow { transform: rotate(90deg); }

/* 内容区:展开时可见;收起用 hidden 属性(读屏也不会读到) */
.kole-m-collapse__panel {
  padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-16);
  border-top: 1px solid var(--kole-color-border);
  color: var(--kole-color-text-secondary);
  font-size: var(--kole-m-font-size-label);
  line-height: 1.7;
}

.kole-m-collapse__panel[hidden] { display: none; }

.kole-m-collapse__content { margin: 0; }

/* 状态 disabled:标题行置灰且不响应 */
.kole-m-collapse__item.is-disabled .kole-m-collapse__header {
  color: var(--kole-color-text-disabled);
  cursor: not-allowed;
}

.kole-m-collapse__item.is-disabled .kole-m-collapse__header:active { background: var(--kole-color-card-bg); }

.kole-m-collapse__header:focus-visible {
  outline: 2px solid var(--kole-color-focus-ring);
  outline-offset: -2px;
}
frameworks-mobile/Collapse.html · H5 原生(无框架) · 193 行
frameworks-mobile/Collapse.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · Collapse(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Collapse.css">
<style>
  body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
  .demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
  .demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
    font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
  .demo-box { background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
  .demo-box--inset { border-block: 0; background: none; }
  .demo-pad { padding: var(--kole-m-gutter); background: none; }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="basic">
    <p class="demo-label">基础用法(点标题行展开:整行热区 ≥52px,箭头转 90°,aria-expanded 同步)</p>
    <div class="demo-box">
      <div class="kole-m-collapse" data-assert="collapse-basic">
        <div class="kole-m-collapse__item" id="col-basic">
          <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="col-basic-panel"
                  data-behavior="click-sets-attr:#col-basic-header|aria-expanded|true" id="col-basic-header">
            <span class="kole-m-collapse__title">配送与运费说明</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="col-basic-panel" hidden>
            <p class="kole-m-collapse__content">单笔满 99 元免运费;偏远地区按实际重量计费,下单页会先给出预估金额。</p>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="multi">
    <p class="demo-label">多面板同时展开(accordion=false:点标题行各自开合,互不影响)</p>
    <div class="demo-box">
      <div class="kole-m-collapse" data-assert="collapse-multi">
        <div class="kole-m-collapse__item is-expanded" id="col-multi-1">
          <button class="kole-m-collapse__header" type="button" aria-expanded="true" aria-controls="col-multi-1-panel"
                  data-behavior="click-toggles-class:#col-multi-1|is-expanded">
            <span class="kole-m-collapse__title">已展开的面板</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="col-multi-1-panel">
            <p class="kole-m-collapse__content">再次点击标题行即可收起;同时打开第二个面板不会影响这一项。</p>
          </div>
        </div>
        <div class="kole-m-collapse__item" id="col-multi-2">
          <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="col-multi-2-panel"
                  data-behavior="click-toggles-class:#col-multi-2|is-expanded">
            <span class="kole-m-collapse__title">收起的面板</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="col-multi-2-panel" hidden>
            <p class="kole-m-collapse__content">移动端屏幕窄,展开后内容会顶走上下文 —— 主题多时优先用手风琴。</p>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="accordion">
    <p class="demo-label">手风琴(accordion=true:展开新面板会自动收起当前展开项)</p>
    <div class="demo-box">
      <div class="kole-m-collapse kole-m-collapse--accordion" data-accordion="true" data-assert="collapse-accordion">
        <div class="kole-m-collapse__item is-expanded" id="acc-1">
          <button class="kole-m-collapse__header" type="button" aria-expanded="true" aria-controls="acc-1-panel">
            <span class="kole-m-collapse__title">支付方式</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="acc-1-panel">
            <p class="kole-m-collapse__content">支持微信支付、支付宝与对公转账;对公转账需 1~2 个工作日到账。</p>
          </div>
        </div>
        <div class="kole-m-collapse__item" id="acc-2">
          <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="acc-2-panel" id="acc-2-header"
                  data-behavior="click-sets-attr:#acc-2-header|aria-expanded|true">
            <span class="kole-m-collapse__title">发票与报销</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="acc-2-panel" hidden>
            <p class="kole-m-collapse__content">电子发票在订单完成后 2 小时内开出,可在订单详情页重新下载。</p>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="bordered">
    <p class="demo-label">边框两态(bordered=true 有外框与分隔线 / false 靠留白分隔)</p>
    <div class="demo-pad" data-assert="collapse-bordered">
      <div class="kole-m-collapse kole-m-collapse--bordered">
        <div class="kole-m-collapse__item is-expanded" id="brd-1">
          <button class="kole-m-collapse__header" type="button" aria-expanded="true" aria-controls="brd-1-panel">
            <span class="kole-m-collapse__title">带边框(bordered=true)</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="brd-1-panel">
            <p class="kole-m-collapse__content">面板之间有 1px 分隔线,组外有圆角边框,适合独立卡片。</p>
          </div>
        </div>
        <div class="kole-m-collapse__item" id="brd-2">
          <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="brd-2-panel">
            <span class="kole-m-collapse__title">同组第二个面板</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="brd-2-panel" hidden>
            <p class="kole-m-collapse__content">分隔线只在面板之间出现,首尾不画。</p>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="disabled">
    <p class="demo-label">禁用面板(标题行置灰、不响应点击,aria-disabled 已置位)</p>
    <div class="demo-box">
      <div class="kole-m-collapse" data-assert="collapse-disabled">
        <div class="kole-m-collapse__item is-disabled" id="col-disabled">
          <button class="kole-m-collapse__header" type="button" aria-expanded="false" aria-controls="col-disabled-panel"
                  aria-disabled="true" disabled>
            <span class="kole-m-collapse__title">企业协议条款(需管理员权限)</span>
            <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
          </button>
          <div class="kole-m-collapse__panel" id="col-disabled-panel" hidden>
            <p class="kole-m-collapse__content">无权限时内容不可展开。</p>
          </div>
        </div>
      </div>
    </div>
  </section>
</div>
<script>
  /* 演示页脚本:真实的展开 / 收起。
     - 点标题行:切换 is-expanded + aria-expanded,并给内容区切 hidden(收起时读屏也读不到)
     - data-accordion="true" 的组:展开一项时自动收起同组的其它项
     真实业务里这份状态由宿主管理(受控 open 值),此处是最小可运行实现。 */
  (function () {
    function itemsOf(root) { return Array.prototype.slice.call(root.querySelectorAll('.kole-m-collapse__item')); }

    function setExpanded(item, on) {
      var header = item.querySelector('.kole-m-collapse__header');
      var panel = item.querySelector('.kole-m-collapse__panel');
      item.classList.toggle('is-expanded', on);
      if (header) header.setAttribute('aria-expanded', on ? 'true' : 'false');
      if (panel) panel.hidden = !on;
    }

    Array.prototype.forEach.call(document.querySelectorAll('.kole-m-collapse'), function (root) {
      var accordion = root.getAttribute('data-accordion') === 'true';
      root.addEventListener('click', function (e) {
        var header = e.target.closest ? e.target.closest('.kole-m-collapse__header') : null;
        if (!header || !root.contains(header)) return;
        var item = header.closest('.kole-m-collapse__item');
        if (!item || item.classList.contains('is-disabled') || header.disabled) return;
        var next = !item.classList.contains('is-expanded');
        if (accordion && next) {
          itemsOf(root).forEach(function (other) { if (other !== item) setExpanded(other, false); });
        }
        setExpanded(item, next);
      });
    });
  })();
</script>
<script>
  /* ?demo=<id> → 只显示该演示块(文档站按块预览用;无参数时全部显示,测试与回归走无参数路径) */
  (function () {
    var id = new URLSearchParams(location.search).get('demo');
    if (!id) return;
    var blocks = Array.prototype.slice.call(document.querySelectorAll('.demo-block'));
    var hit = false;
    blocks.forEach(function (b) {
      var on = b.getAttribute('data-demo') === id;
      if (on) hit = true;
      b.hidden = !on;
    });
    if (!hit) { blocks.forEach(function (b) { b.hidden = false; }); return; }
    document.body.classList.add('demo-single');
    blocks.forEach(function (b) {
      var label = b.querySelector('.demo-label');
      if (label && !b.hidden) label.hidden = true;
    });
  })();
</script>
</body>
</html>
frameworks-mobile/Collapse.jsx · React · 95 行
frameworks-mobile/Collapse.jsx
import React, { useRef, useState } from 'react';
import './Collapse.css';

/* 折叠面板(移动端)— 规格 §25
   标题行是原生 button(整行热区、键盘可达)+ aria-expanded / aria-controls;
   内容区收起时用 hidden 属性(而不是只靠 CSS 高度),读屏也不会读到收起的内容。
   accordion=true 时展开新面板会收起当前展开项,其余模式互不影响。
   本端不发明展开动画(内容区直接切 hidden):面板高度本由浏览器决定,不做高度换算。 */

/* 面板标题 / 内容区的 id:同页多实例不撞号(模块级自增即可) */
let seq = 0;

export default function Collapse({
  items = [],
  accordion = false,
  bordered = true,
  value = [],
  onChange,
  children = null,
}) {
  const idRef = useRef(null);
  if (idRef.current === null) {
    seq += 1;
    idRef.current = 'kole-m-collapse-' + seq;
  }
  const base = idRef.current;

  /* null = 跟随 value 属性;点击后本地记账(与 DatePicker 同一约定) */
  const [open, setOpen] = useState(null);
  const expanded = open || value;

  function toggle(key) {
    const has = expanded.indexOf(key) >= 0;
    const next = accordion
      ? has
        ? []
        : [key]
      : has
        ? expanded.filter((k) => k !== key)
        : expanded.concat([key]);
    setOpen(next);
    if (onChange) onChange(key, !has);
  }

  return (
    <div
      className={
        'kole-m-collapse' +
        (accordion ? ' kole-m-collapse--accordion' : '') +
        (bordered ? ' kole-m-collapse--bordered' : '')
      }
    >
      {items.map((it, i) => {
        const key = it && it.key !== undefined ? it.key : i;
        const isOpen = expanded.indexOf(key) >= 0;
        const headerId = `${base}-header-${key}`;
        const panelId = `${base}-panel-${key}`;
        return (
          <div
            className={
              'kole-m-collapse__item' +
              (isOpen ? ' is-expanded' : '') +
              (it && it.disabled ? ' is-disabled' : '')
            }
            key={String(key)}
          >
            <button
              className="kole-m-collapse__header"
              type="button"
              id={headerId}
              aria-expanded={isOpen ? 'true' : 'false'}
              aria-controls={panelId}
              aria-disabled={it && it.disabled ? 'true' : undefined}
              disabled={!!(it && it.disabled)}
              onClick={() => {
                if (it && it.disabled) return;
                toggle(key);
              }}
            >
              <span className="kole-m-collapse__title">{it && it.title}</span>
              <span className="kole-m-collapse__arrow" aria-hidden="true">
                ›
              </span>
            </button>
            <div className="kole-m-collapse__panel" id={panelId} aria-labelledby={headerId} hidden={!isOpen}>
              {it && it.content}
            </div>
          </div>
        );
      })}
      {children}
    </div>
  );
}
frameworks-mobile/Collapse.vue2.vue · Vue 2 · 90 行
frameworks-mobile/Collapse.vue2.vue
<template>
  <div class="kole-m-collapse" :class="collapseClass">
    <div
      v-for="(it, i) in items"
      :key="String(keyOf(it, i))"
      class="kole-m-collapse__item"
      :class="{ 'is-expanded': isOpen(it, i), 'is-disabled': it && it.disabled }"
    >
      <button
        class="kole-m-collapse__header"
        type="button"
        :id="headerId(it, i)"
        :aria-expanded="isOpen(it, i) ? 'true' : 'false'"
        :aria-controls="panelId(it, i)"
        :aria-disabled="it && it.disabled ? 'true' : null"
        :disabled="!!(it && it.disabled)"
        @click="toggle(it, i)"
      >
        <span class="kole-m-collapse__title">{{ it && it.title }}</span>
        <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
      </button>
      <div
        class="kole-m-collapse__panel"
        :id="panelId(it, i)"
        :aria-labelledby="headerId(it, i)"
        :hidden="!isOpen(it, i)"
      >{{ it && it.content }}</div>
    </div>
    <slot></slot>
  </div>
</template>

<script>
/* 面板标题 / 内容区的 id:同页多实例不撞号(模块级自增即可) */
var seq = 0;

export default {
  name: 'KoleMCollapse',
  props: {
    items: { type: Array, default: function () { return []; } },
    accordion: { type: Boolean, default: false },
    bordered: { type: Boolean, default: true },
    value: { type: Array, default: function () { return []; } }
  },
  data: function () {
    seq += 1;
    return { open: null, base: 'kole-m-collapse-' + seq };
  },
  computed: {
    collapseClass: function () {
      return [
        this.accordion ? 'kole-m-collapse--accordion' : '',
        this.bordered ? 'kole-m-collapse--bordered' : ''
      ].filter(Boolean);
    }
  },
  methods: {
    keyOf: function (it, i) {
      return it && it.key !== undefined ? it.key : i;
    },
    current: function () {
      return this.open || this.value;
    },
    isOpen: function (it, i) {
      return this.current().indexOf(this.keyOf(it, i)) >= 0;
    },
    headerId: function (it, i) {
      return this.base + '-header-' + this.keyOf(it, i);
    },
    panelId: function (it, i) {
      return this.base + '-panel-' + this.keyOf(it, i);
    },
    toggle: function (it, i) {
      if (it && it.disabled) return;
      var key = this.keyOf(it, i);
      var cur = this.current();
      var has = cur.indexOf(key) >= 0;
      if (this.accordion) {
        this.open = has ? [] : [key];
      } else {
        this.open = has ? cur.filter(function (k) { return k !== key; }) : cur.concat([key]);
      }
      this.$emit('change', key, !has);
    }
  }
};
</script>

<style src="./Collapse.css"></style>
frameworks-mobile/Collapse.vue3.vue · Vue 3 · 80 行
frameworks-mobile/Collapse.vue3.vue
<template>
  <div class="kole-m-collapse" :class="collapseClass">
    <div
      v-for="(it, i) in items"
      :key="String(keyOf(it, i))"
      class="kole-m-collapse__item"
      :class="{ 'is-expanded': isOpen(it, i), 'is-disabled': it && it.disabled }"
    >
      <button
        class="kole-m-collapse__header"
        type="button"
        :id="headerId(it, i)"
        :aria-expanded="isOpen(it, i) ? 'true' : 'false'"
        :aria-controls="panelId(it, i)"
        :aria-disabled="it && it.disabled ? 'true' : null"
        :disabled="!!(it && it.disabled)"
        @click="toggle(it, i)"
      >
        <span class="kole-m-collapse__title">{{ it && it.title }}</span>
        <span class="kole-m-collapse__arrow" aria-hidden="true">›</span>
      </button>
      <div
        class="kole-m-collapse__panel"
        :id="panelId(it, i)"
        :aria-labelledby="headerId(it, i)"
        :hidden="!isOpen(it, i)"
      >{{ it && it.content }}</div>
    </div>
    <slot></slot>
  </div>
</template>

<script setup>
/* 折叠面板(移动端)— 规格 §25:标题行是原生 button + aria-expanded / aria-controls,
   内容区收起时用 hidden 属性(只靠 CSS 高度会让读屏继续读到收起的内容)。 */
import { computed, ref } from 'vue';

const props = defineProps({
  items: { type: Array, default: () => [] },
  accordion: { type: Boolean, default: false },
  bordered: { type: Boolean, default: true },
  value: { type: Array, default: () => [] }
});
const emit = defineEmits(['change']);

/* null = 跟随 value 属性;点击后本地记账(与 DatePicker 同一约定) */
const open = ref(null);

const collapseClass = computed(() => [
  props.accordion ? 'kole-m-collapse--accordion' : '',
  props.bordered ? 'kole-m-collapse--bordered' : ''
].filter(Boolean));

function keyOf(it, i) {
  return it && it.key !== undefined ? it.key : i;
}
function current() {
  return open.value || props.value;
}
function isOpen(it, i) {
  return current().indexOf(keyOf(it, i)) >= 0;
}
function toggle(it, i) {
  if (it && it.disabled) return;
  const key = keyOf(it, i);
  const cur = current();
  const has = cur.indexOf(key) >= 0;
  open.value = props.accordion
    ? has
      ? []
      : [key]
    : has
      ? cur.filter((k) => k !== key)
      : cur.concat([key]);
  emit('change', key, !has);
}
</script>

<style src="./Collapse.css"></style>
frameworks-mobile/Collapse.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 146 行
frameworks-mobile/Collapse.uniapp.vue
<template>
  <view class="kole-m-collapse" :class="collapseClass">
    <view
      v-for="(it, i) in items"
      :key="i"
      class="kole-m-collapse__item"
      :class="{ 'is-expanded': isOpen(it, i), 'is-disabled': it && it.disabled }"
    >
      <view
        class="kole-m-collapse__header"
        :role="it && it.disabled ? '' : 'button'"
        :aria-expanded="isOpen(it, i) ? 'true' : 'false'"
        :aria-disabled="it && it.disabled ? 'true' : 'false'"
        @tap="toggle(it, i)"
      >
        <text class="kole-m-collapse__title">{{ it && it.title }}</text>
        <text class="kole-m-collapse__arrow">›</text>
      </view>
      <view class="kole-m-collapse__panel" v-show="isOpen(it, i)">
        <text class="kole-m-collapse__content">{{ it && it.content }}</text>
      </view>
    </view>
    <slot></slot>
  </view>
</template>

<script setup>
/* uni-app 端 · 折叠面板(移动端)— 规格 §25
   跨端差异:标题行用 view + role="button"(小程序没有可聚焦的原生 button 语义),
   点击用 @tap,展开态同时写 aria-expanded 与 is-expanded;
   内容区用 v-show 切换(小程序里 hidden 属性不可靠,v-show 编译成 display 控制更稳)。
   尺寸用 rpx(88rpx = 375pt 下的 44px,故标题行 104rpx)。 */
import { computed, ref } from 'vue';

const props = defineProps({
  items: { type: Array, default: () => [] },
  accordion: { type: Boolean, default: false },
  bordered: { type: Boolean, default: true },
  value: { type: Array, default: () => [] }
});
const emit = defineEmits(['change']);

/* null = 跟随 value 属性;点击后本地记账(与 DatePicker 同一约定) */
const open = ref(null);

const collapseClass = computed(() => [
  props.accordion ? 'kole-m-collapse--accordion' : '',
  props.bordered ? 'kole-m-collapse--bordered' : ''
].filter(Boolean));

function keyOf(it, i) {
  return it && it.key !== undefined ? it.key : i;
}
function current() {
  return open.value || props.value;
}
function isOpen(it, i) {
  return current().indexOf(keyOf(it, i)) >= 0;
}
function toggle(it, i) {
  if (it && it.disabled) return;
  const key = keyOf(it, i);
  const cur = current();
  const has = cur.indexOf(key) >= 0;
  open.value = props.accordion
    ? has
      ? []
      : [key]
    : has
      ? cur.filter((k) => k !== key)
      : cur.concat([key]);
  emit('change', key, !has);
}
</script>

<style>
.kole-m-collapse {
  --kole-m-collapse-header-height: 104rpx;  /* 标题行高度(≥ 88rpx 触控最小边长) */
  --kole-m-collapse-duration: 180ms;
  --kole-m-touch-target: 88rpx;
  --kole-m-font-size-body: 32rpx;
  --kole-m-font-size-label: 28rpx;
  --kole-m-gutter: 32rpx;
  box-sizing: border-box;
  width: 100%;
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-body);
}

.kole-m-collapse--bordered {
  border: 1rpx solid var(--kole-color-border);
  border-radius: 16rpx;
  overflow: hidden;
}

.kole-m-collapse--bordered .kole-m-collapse__item + .kole-m-collapse__item {
  border-top: 1rpx solid var(--kole-color-border);
}

.kole-m-collapse__header {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  min-height: var(--kole-m-collapse-header-height);
  padding: 24rpx var(--kole-m-gutter);
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-title);
  font-size: inherit;
}

/* 状态 active:按下反馈 */
.kole-m-collapse__header:active { background-color: var(--kole-color-table-header-bg); }

.kole-m-collapse__title {
  flex: 1;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* 箭头:收起指右,展开转 90° 指下 */
.kole-m-collapse__arrow {
  flex-shrink: 0;
  padding-left: 24rpx;
  color: var(--kole-color-text-placeholder);
  transition: transform var(--kole-m-collapse-duration) ease;
}

.kole-m-collapse__item.is-expanded .kole-m-collapse__arrow { transform: rotate(90deg); }

.kole-m-collapse__panel {
  padding: 24rpx var(--kole-m-gutter) 32rpx;
  border-top: 1rpx solid var(--kole-color-border);
  color: var(--kole-color-text-secondary);
  font-size: var(--kole-m-font-size-label);
  line-height: 1.7;
}

.kole-m-collapse__item.is-disabled .kole-m-collapse__header {
  color: var(--kole-color-text-disabled);
}
</style>

测试与回归

断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。

断言 19 条 · 全部通过 报告 2026-09-22 23:01:05

复现命令
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs   # 全量 5 个组件
npm run verify:mobile-docs                                           # 本页内容完整性 + API 与源码一致性

设计契约

components/mobile-collapse.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "25 · 折叠面板 Collapse",
  "confidence": "high",
  "slug": "mobile-collapse",
  "name": "折叠面板 Collapse",
  "semanticTypeCandidates": [
    "collapse",
    "accordion",
    "disclosure"
  ],
  "variantDimensions": [
    {
      "name": "accordion",
      "values": [
        "false",
        "true"
      ]
    },
    {
      "name": "bordered",
      "values": [
        "true",
        "false"
      ]
    }
  ],
  "representativeVariants": [
    {
      "accordion": "false",
      "bordered": "true",
      "label": "多面板带边框(默认:各自开合互不影响)"
    },
    {
      "accordion": "true",
      "bordered": "true",
      "label": "手风琴(同时只展开一个,移动端推荐形态)"
    },
    {
      "accordion": "false",
      "bordered": "false",
      "label": "无边框面板(靠留白分隔,适合嵌在卡片内)"
    }
  ],
  "anatomy": {
    "collapse": "根元素,一组面板的容器",
    "item": "单个面板,含标题行与内容区",
    "header": "标题行,整行可点,高度不小于 44px",
    "arrow": "标题行右侧箭头,展开时旋转 90 度",
    "panel": "内容区,展开时可见(收起时高度为 0 或 hidden)",
    "content": "内容区内的正文节点"
  },
  "structurePatterns": {
    "accordion": "false(多面板可同时展开)/ true(手风琴,同时只展开一个)",
    "bordered": "true(面板之间有分隔线与外框)/ false(无边框,靠留白分隔)"
  },
  "usageHints": [
    "把长内容按主题收起来,让用户先看到标题、按需展开某一段",
    "移动端屏幕窄,展开后内容会顶走上下文,因此一次只展开一个(手风琴)是默认推荐形态",
    "展开态必须明确到不靠颜色也能看出(箭头方向 + aria-expanded)",
    "标题行整行都是热区,高度不小于 44px",
    "手风琴模式下展开新面板会收起当前展开项;accordion=false 时互不影响"
  ],
  "doNotInvent": [
    "展开动画的高度换算公式(内容高度由浏览器决定)",
    "嵌套折叠面板的层级样式"
  ],
  "unknowns": [
    "默认是否展开第一项",
    "标题行右侧是否允许放额外操作"
  ],
  "interaction": [
    "标题行整行都是热区,高度不小于 44px",
    "视觉箭头转 90 度(180ms 过渡),展开时 aria-expanded 同步为 true",
    "手风琴模式下展开新面板会收起当前展开项;accordion=false 时互不影响",
    "内容区不做高度动画,直接切换 hidden(省电,读屏也不会读到中间态);减少动态偏好下箭头同样瞬时切换"
  ],
  "accessibility": [
    "标题行用原生 button 并带 aria-expanded / aria-controls",
    "内容区与标题用 id / aria-controls 建立关联,收起时用 hidden 属性隐藏(而不是只靠 CSS 高度)",
    "禁用项置 aria-disabled=\"true\" 且不可聚焦"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "items",
        "type": "Array",
        "default": "[]",
        "desc": "数据驱动的面板数组,每项含 key / title / content / disabled(规格 §25.2)",
        "required": false
      },
      {
        "name": "accordion",
        "type": "boolean",
        "default": "false",
        "desc": "变体 accordion:手风琴模式,展开新面板时收起当前展开项(规格 §25.3)",
        "required": false
      },
      {
        "name": "bordered",
        "type": "boolean",
        "default": "true",
        "desc": "变体 bordered:面板之间有分隔线与外框(规格 §25.3)",
        "required": false
      },
      {
        "name": "value",
        "type": "Array",
        "default": "[]",
        "desc": "受控展开项:面板 key 的数组,点击后由 change 事件回传(规格 §25.5)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "change",
        "params": "(key, expanded)",
        "desc": "面板标题行被点击后触发,回传该面板 key 与展开态布尔值(规格 §25.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "追加在数据驱动面板之后的自定义面板(规格 §25.2 item)"
      }
    ]
  },
  "variantClasses": {
    "accordion": {
      "false": [],
      "true": [
        ".kole-m-collapse--accordion"
      ]
    },
    "bordered": {
      "true": [
        ".kole-m-collapse--bordered"
      ],
      "false": []
    }
  },
  "demos": [
    {
      "id": "basic",
      "group": "01 组件类型",
      "title": "基础用法",
      "desc": "点标题行展开:整行热区不小于 52px,箭头转 90 度,aria-expanded 同步变化。",
      "variant": "accordion=false"
    },
    {
      "id": "multi",
      "group": "01 组件类型",
      "title": "多面板同时展开",
      "desc": "accordion=false 时各面板各自开合,互不影响。",
      "variant": "accordion=false"
    },
    {
      "id": "accordion",
      "group": "01 组件类型",
      "title": "手风琴",
      "desc": "accordion=true 时展开新面板会自动收起当前展开项,适合主题多、屏幕窄的场合。",
      "variant": "accordion=true"
    },
    {
      "id": "bordered",
      "group": "02 组件状态",
      "title": "边框两态",
      "desc": "bordered=true 有外框与面板间分隔线;false 时靠留白分隔。",
      "variant": "bordered=true|false"
    },
    {
      "id": "disabled",
      "group": "02 组件状态",
      "title": "禁用面板",
      "desc": "状态 disabled:标题行置灰、不响应点击,aria-disabled 已置位且不可聚焦。",
      "variant": "状态 disabled"
    }
  ],
  "related": [
    {
      "slug": "mobile-list",
      "why": "行点击后跳转或选中用列表;就地展开内容用折叠面板"
    },
    {
      "slug": "mobile-popup",
      "why": "内容需要遮罩聚焦时用弹出层;内容属于当前上下文的补充说明时用折叠面板"
    },
    {
      "slug": "mobile-dialog",
      "why": "需要用户先决策再继续时用对话框;只是看说明用折叠面板"
    }
  ]
}