折叠面板Collapse
把长内容按主题收起来,让用户先看到标题、按需展开某一段
数据展示 规格 25 · 折叠面板 Collapse 6 端实现 触摸优先
<!-- ① 令牌: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 组件类型
点标题行展开:整行热区不小于 52px,箭头转 90 度,aria-expanded 同步变化。
查看代码(演示页原文 · 17 行)
<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 时各面板各自开合,互不影响。
查看代码(演示页原文 · 27 行)
<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 时展开新面板会自动收起当前展开项,适合主题多、屏幕窄的场合。
查看代码(演示页原文 · 26 行)
<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 时靠留白分隔。
查看代码(演示页原文 · 25 行)
<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:标题行置灰、不响应点击,aria-disabled 已置位且不可聚焦。
查看代码(演示页原文 · 17 行)
<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
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
items | Array | [] | 数据驱动的面板数组,每项含 key / title / content / disabled(规格 §25.2) | N |
accordion | boolean | false | 变体 accordion:手风琴模式,展开新面板时收起当前展开项(规格 §25.3) | N |
bordered | boolean | true | 变体 bordered:面板之间有分隔线与外框(规格 §25.3) | N |
value | Array | [] | 受控展开项:面板 key 的数组,点击后由 change 事件回传(规格 §25.5) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
change | (key, expanded) | 面板标题行被点击后触发,回传该面板 key 与展开态布尔值(规格 §25.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 追加在数据驱动面板之后的自定义面板(规格 §25.2 item) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-collapse-header-height | 52px | 标题行高度(≥ 44px 触控最小边长) |
--kole-m-collapse-duration | 180ms | 箭头旋转时长 |
何时使用
- 把长内容按主题收起来,让用户先看到标题、按需展开某一段
- 移动端屏幕窄,展开后内容会顶走上下文,因此一次只展开一个(手风琴)是默认推荐形态
- 展开态必须明确到不靠颜色也能看出(箭头方向 + 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 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
accordion | false / true | false (由数据驱动,无专属类) true .kole-m-collapse--accordion |
bordered | true / false | true .kole-m-collapse--bordered false (由数据驱动,无专属类) |
代表变体
| 变体 | 标签 |
|---|---|
accordion=false · bordered=true | 多面板带边框(默认:各自开合互不影响) |
accordion=true · bordered=true | 手风琴(同时只展开一个,移动端推荐形态) |
accordion=false · bordered=false | 无边框面板(靠留白分隔,适合嵌在卡片内) |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Collapse.css · 纯样式(CSS) · 105 行
/* 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 行
<!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 行
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 行
<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 行
<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 行
<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-20 16:50:33
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": "需要用户先决策再继续时用对话框;只是看说明用折叠面板"
}
]
}