级联选择器Cascader
在有层级关系的选项里逐级选到末级(省 → 市 → 区、品类 → 子品类 → SKU)
数据录入 规格 44 · 级联选择器 Cascader 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-cascader.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Cascader.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
点省份后下一级换成城市:面板只渲染当前一层,避免 375px 下横向挤压。
查看代码(演示页原文 · 16 行)
<section class="demo-block" data-demo="levels">
<p class="demo-label">逐级下钻(点省份 → 下一级换成城市;每级只渲染当前一层,避免 375px 下横向挤压)</p>
<div class="kole-m-cascader kole-m-cascader--panel" id="cas-levels" data-assert="cascader-levels"
data-path="" data-level="0" data-value="">
<div class="kole-m-cascader__path" hidden></div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="省份">
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="zj"
data-behavior="click-sets-attr:#cas-levels|data-level|1">
<span class="kole-m-cascader__label">浙江省</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="gd">
<span class="kole-m-cascader__label">广东省</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
</ul>
</div>
</section>mode=popup:自带遮罩与确定取消,遮罩点击关闭。
查看代码(演示页原文 · 32 行)
<section class="demo-block" data-demo="popup">
<p class="demo-label">底部浮层(mode=popup:默认收起,遮罩点击关闭;标题读屏可读)</p>
<div class="demo-frame demo-frame--tall" data-assert="cascader-popup">
<div class="kole-m-cascader__mask is-open" aria-hidden="true"></div>
<div class="kole-m-cascader kole-m-cascader--popup kole-m-cascader--round is-open" id="cas-popup"
role="dialog" aria-modal="true" aria-labelledby="cas-popup-title" data-level="2" data-value="zj/hz">
<div class="kole-m-cascader__header">
<button class="kole-m-cascader__btn" type="button" id="cas-popup-cancel"
data-behavior="click-sets-attr:#cas-popup|data-cancelled|true">取消</button>
<span class="kole-m-cascader__title" id="cas-popup-title">选择地区</span>
<button class="kole-m-cascader__btn kole-m-cascader__btn--confirm" type="button" id="cas-popup-confirm"
data-behavior="click-sets-attr:#cas-popup|data-confirmed|true">确定</button>
</div>
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item" type="button" data-depth="0">浙江省</button>
<span class="kole-m-cascader__sep" aria-hidden="true">›</span>
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="1">杭州市</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="区县">
<li class="kole-m-cascader__option is-selected" role="option" aria-selected="true" data-value="xh">
<span class="kole-m-cascader__label">西湖区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="bj">
<span class="kole-m-cascader__label">滨江区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="yh">
<span class="kole-m-cascader__label">余杭区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
</ul>
</div>
</div>
</section>02 组件状态
点路径条里的上级即退回该级,重新展示它的下一级;末位是「你在这里」的锚点。
查看代码(演示页原文 · 20 行)
<section class="demo-block" data-demo="back">
<p class="demo-label">回退上一级(点路径条里的上级即退回该级;末位是「你在这里」的锚点,不可点)</p>
<div class="kole-m-cascader kole-m-cascader--panel kole-m-cascader--round" id="cas-back" data-assert="cascader-back"
data-path="zj,hz" data-level="2" data-value="zj/hz">
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item" type="button" data-depth="0"
data-behavior="click-sets-attr:#cas-back|data-level|1">浙江省</button>
<span class="kole-m-cascader__sep" aria-hidden="true">›</span>
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="1">杭州市</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="区县">
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="xh">
<span class="kole-m-cascader__label">西湖区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="bj">
<span class="kole-m-cascader__label">滨江区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
</ul>
</div>
</section>禁用结点置灰并写 aria-disabled;当前级无子项时显示占位文案。
查看代码(演示页原文 · 27 行)
<section class="demo-block" data-demo="states">
<p class="demo-label">禁用项与空态(禁用行置灰并写 aria-disabled,点击不响应;该级无子项时显示占位)</p>
<div class="kole-m-cascader kole-m-cascader--panel" id="cas-states" data-assert="cascader-states"
data-path="gd" data-level="1" data-value="gd">
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="0">广东省</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="城市">
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="gz">
<span class="kole-m-cascader__label">广州市</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
<li class="kole-m-cascader__option is-disabled" role="option" aria-selected="false" aria-disabled="true" data-value="sz">
<span class="kole-m-cascader__label">深圳市(暂未开放)</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
</ul>
</div>
<div class="kole-m-cascader kole-m-cascader--panel" data-assert="cascader-empty"
data-path="gd,sz" data-level="2" data-value="gd/sz">
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item" type="button" data-depth="0">广东省</button>
<span class="kole-m-cascader__sep" aria-hidden="true">›</span>
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="1">深圳市</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="区县"></ul>
<p class="kole-m-cascader__empty">该市暂无开通区县</p>
</div>
</section>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
mode | 'panel' | 'popup' | 'panel' | 变体 mode:panel 内嵌在页面里,popup 是底部浮层(规格 §44.3) | N |
round | boolean | false | 变体 round:浮层靠内容一侧是否切圆角(规格 §44.3) | N |
showPath | boolean | true | 变体 showPath:是否显示路径条(规格 §44.3) | N |
open | boolean | false | 状态 open:mode=popup 时浮层是否展开(规格 §44.4) | N |
closeOnMask | boolean | true | 遮罩点击是否关闭(规格 §44.5) | N |
value | Array<string> | [] | 当前选中路径,从根到当前级的 value 序列(规格 §44.6) | N |
options | Array<string | { label: string, value: string, disabled?: boolean, children?: Array }> | [] | 层级数据树(宿主提供,本组件不发明省市区库)(规格 §44.7) | N |
disabled | boolean | false | 状态 disabled:整块置灰且不响应(规格 §44.4) | N |
title | string | '' | 浮层标题(空串时用默认「选择地区」)(规格 §44.2 cascader__header) | N |
label | string | '级联选项' | 选项区 aria-label 的前缀,读屏靠它知道这组在选什么(规格 §44.6) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
change | value: Array<string> | 路径变化时触发(下钻或回退),回传从根到当前级的 value 序列(规格 §44.5) |
confirm | value: Array<string> | mode=popup 时点击确定触发(规格 §44.5) |
close | — | mode=popup 时点击取消或遮罩(closeOnMask=true)触发(规格 §44.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 自定义内容(追加在面板之前,常见用法是放触发器) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-cascader-row-height | var(--kole-m-touch-target) | 选项行高(≥44px 触控最小边长) |
--kole-m-cascader-panel-height | 240px | 选项区最大高度 |
何时使用
- 在有层级关系的选项里逐级选到末级(省 → 市 → 区、品类 → 子品类 → SKU)
- 移动端一次只展示当前一层,已选路径收进上方路径条,靠路径条回退而不是靠「上一级」按钮
- 点某一级后下一级选项随之变化:面板始终只渲染当前一层,选择即下钻
- 点路径条里的上级可回退:回退到该级并重新展示它的下一级;末位是「你在这里」的锚点,不可点
- 级数不写死:路径条按已选深度渲染,两级与四级用同一份实现
- 选项行高与路径条各项热区均不小于 44px
交互与触控
- 点某一级后下一级选项随之变化:面板始终只渲染当前一层,选择即下钻
- 点路径条里的上级可回退:回退到该级并重新展示它的下一级;末位是「你在这里」的锚点,不可点
- 级数不写死:路径条按已选深度渲染,两级与四级用同一份实现
- 点到叶子结点后再点同级另一项,会截断更深的层级(改选不会留下旧的深层残留)
- 选项行高与路径条各项热区均不小于 44px;选项区滚动容器 -webkit-overflow-scrolling: touch
无障碍
- mode=panel 根元素 role="group" + aria-label;mode=popup 根元素 role="dialog" + aria-modal="true"
- 选项区 role="listbox",aria-label 随层级变化(如「级联选项 · 城市」),读屏能播报当前在第几级
- 选项 role="option" + aria-selected;禁用项写 aria-disabled="true"
- 路径条末位写 aria-current="true",读屏播报「当前项」
- 路径条每一项是原生 button(键盘可达),末位用 disabled 让键盘跳过
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 选择器Picker | 选项之间没有层级关系(城市平铺)时用选择器,不要用级联去拼一棵假树 |
| 弹出层Popup | 通用弹出层;mode=popup 本身就是底部浮层,不要在弹出层里再套一层 |
| 单元格Cell | 「已选地区」的展示入口用单元格,点开才拉起级联 |
| 列表List | 只需要展示一层可选项且不需要回退路径时,列表更轻 |
| 动作面板ActionSheet | 动作面板面向「点一下即执行」,级联面向「逐级缩小范围再确认」 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 层级数据源(由宿主传入 options 树,本组件不发明省市区库) |
| 禁止发明 | 搜索定位某级选项(那需要把整棵树拍平,是独立的检索组件) |
| 禁止发明 | 异步逐级加载的占位与重试流程 |
| 禁止发明 | 多选(一次选多条路径) |
| 规格未定 | 路径很长时路径条是折行还是横向滚动(当前折行,四级以上会占两行) |
| 规格未定 | 叶子被选中后是否要自动收起浮层(当前不自动收,等宿主决定) |
| 规格未定 | 是否要保留「上一级」按钮作为路径条之外的第二种回退入口 |
结构(anatomy)
| 字段 | 说明 |
|---|---|
cascader | 根元素,mode=panel 时内嵌在页面里,mode=popup 时是底部浮层 |
mask | 遮罩(仅 mode=popup),点击关闭 |
cascader__header | 取消 / 标题 / 确定三格(仅 mode=popup) |
cascader__path | 路径条,按已选深度渲染;除末位外都可点,点了回退到该级 |
cascader__panel | 选项区,role=listbox + aria-label 说明当前在选第几级 |
cascader__option | 单个选项,role=option + aria-selected;有下级显示箭头,叶子显示勾号 |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
mode | panel / popup | panel .kole-m-cascader--panel popup .kole-m-cascader--popup |
showPath | true / false | true (由数据驱动,无专属类) false .kole-m-cascader--no-path |
round | false / true | false (由数据驱动,无专属类) true .kole-m-cascader--round |
代表变体
| 变体 | 标签 |
|---|---|
mode=panel · showPath=true · round=false | 内嵌面板(页面内逐级选择) |
mode=popup · showPath=true · round=true | 底部浮层(带遮罩与确定取消) |
mode=panel · showPath=false · round=false | 不显示路径条(层级少时省一行高度) |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Cascader.css · 纯样式(CSS) · 258 行
/* Kole UI Mobile · Cascader 样式 — 对齐移动端规格 §44
级联选择器:多级联动,点某一级后下一级选项变化,点路径条可回退上一级。
级数不写死:路径条按已选深度排版,面板只渲染当前一级(一次只看一层,避免移动端横向挤压)。 */
.kole-m-cascader {
/* 组件级变量:业务侧可在容器上覆盖 */
--kole-m-cascader-row-height: var(--kole-m-touch-target); /* 选项行高(≥44px 触控最小边长) */
--kole-m-cascader-panel-height: 240px; /* 选项区最大高度 */
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: var(--kole-font-family);
font-size: var(--kole-m-font-size-body);
line-height: 1.4;
}
.kole-m-cascader__mask {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--kole-color-mask);
opacity: 0;
pointer-events: none;
transition: opacity var(--kole-m-duration-slide) var(--kole-m-ease-slide);
}
.kole-m-cascader__mask.is-open {
opacity: 1;
pointer-events: auto;
}
/* 变体 mode=panel:内嵌在页面里,跟随文档流 */
.kole-m-cascader--panel {
border: 1px solid var(--kole-color-border);
border-radius: var(--kole-radius-base);
overflow: hidden;
}
/* 变体 mode=popup:底部浮层(默认收起,靠 is-open 滑入) */
.kole-m-cascader--popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2001;
padding-bottom: var(--kole-m-safe-bottom);
transform: translateY(100%);
transition: transform var(--kole-m-duration-slide) var(--kole-m-ease-slide);
}
.kole-m-cascader--popup.is-open { transform: translateY(0); }
/* 变体 showPath=false:不显示路径条(层级少的场景省一行高度) */
.kole-m-cascader--no-path .kole-m-cascader__path { display: none; }
/* 变体 round=true:浮层靠内容一侧切圆角 */
.kole-m-cascader--round {
border-start-start-radius: var(--kole-radius-large);
border-start-end-radius: var(--kole-radius-large);
}
.kole-m-cascader__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--kole-space-8);
padding: var(--kole-space-8) var(--kole-m-gutter);
border-bottom: 1px solid var(--kole-color-border);
}
.kole-m-cascader__title {
color: var(--kole-color-text-title);
font-weight: 500;
}
.kole-m-cascader__btn {
box-sizing: border-box;
min-height: var(--kole-m-touch-target);
padding: 0 var(--kole-space-8);
border: 0;
border-radius: var(--kole-radius-base);
background: none;
color: var(--kole-color-brand);
font-family: inherit;
font-size: var(--kole-m-font-size-label);
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-cascader__btn:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: 2px;
}
.kole-m-cascader__btn:active { background: var(--kole-color-brand-bg); }
/* 路径条:显示已选层级,点某一级回退到该级(变体 showPath=true 时出现) */
.kole-m-cascader__path {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--kole-space-4);
min-height: var(--kole-m-touch-target);
padding: var(--kole-space-4) var(--kole-m-gutter);
border-bottom: 1px solid var(--kole-color-border);
background: var(--kole-color-page-bg);
font-size: var(--kole-m-font-size-label);
color: var(--kole-color-text-secondary);
}
.kole-m-cascader__path-item {
box-sizing: border-box;
min-height: var(--kole-m-touch-target);
padding: 0 var(--kole-space-8);
border: 0;
border-radius: var(--kole-radius-base);
background: none;
color: var(--kole-color-brand);
font-family: inherit;
font-size: inherit;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-cascader__path-item:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
.kole-m-cascader__path-item:active { background: var(--kole-color-brand-bg); }
/* 当前层级(不可点,是「你在这里」的锚点) */
.kole-m-cascader__path-item.is-current {
color: var(--kole-color-text-body);
cursor: default;
}
.kole-m-cascader__sep {
color: var(--kole-color-text-placeholder);
font-size: var(--kole-m-font-size-caption);
}
.kole-m-cascader__panel {
display: flex;
flex-direction: column;
max-height: var(--kole-m-cascader-panel-height);
margin: 0;
padding: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
list-style: none;
}
.kole-m-cascader__option {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--kole-space-8);
width: 100%;
min-height: var(--kole-m-cascader-row-height);
padding: var(--kole-space-8) var(--kole-m-gutter);
margin: 0;
border: 0;
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: inherit;
line-height: 1.4;
text-align: start;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-cascader__option + .kole-m-cascader__option {
border-top: 1px solid var(--kole-color-border);
}
.kole-m-cascader__option:active { background: var(--kole-color-table-header-bg); }
.kole-m-cascader__option:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
/* 状态 selected:当前路径上的项(品牌色高亮,与 aria-selected 同步) */
.kole-m-cascader__option.is-selected {
color: var(--kole-color-brand);
font-weight: 500;
}
.kole-m-cascader__option[aria-selected="true"] {
color: var(--kole-color-brand);
font-weight: 500;
}
/* 状态 disabled:置灰且不响应 */
.kole-m-cascader__option.is-disabled {
color: var(--kole-color-text-disabled);
cursor: not-allowed;
}
.kole-m-cascader__option.is-disabled:active { background: var(--kole-color-card-bg); }
.kole-m-cascader__label { flex: 1 1 auto; min-width: 0; }
/* 展开指示:有下级用 ›,叶子用 ✓(形状差异,不只靠颜色) */
.kole-m-cascader__arrow {
flex: 0 0 auto;
color: var(--kole-color-text-placeholder);
}
.kole-m-cascader__option.is-selected .kole-m-cascader__check { color: var(--kole-color-brand); }
.kole-m-cascader__check {
flex: 0 0 auto;
color: var(--kole-color-brand);
}
/* 状态 empty:当前级没有可选项 */
.kole-m-cascader__empty {
margin: 0;
padding: var(--kole-space-40) var(--kole-m-gutter);
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
text-align: center;
}
/* 状态 disabled:整块置灰且不响应 */
.kole-m-cascader.is-disabled .kole-m-cascader__option,
.kole-m-cascader.is-disabled .kole-m-cascader__path-item { color: var(--kole-color-text-disabled); }
.kole-m-cascader.is-disabled .kole-m-cascader__option { cursor: not-allowed; }
/* 演示页触发器(非组件交付样式) */
.kole-m-cascader__trigger {
min-height: var(--kole-m-touch-target);
padding: 0 var(--kole-m-gutter);
border: 1px solid var(--kole-color-border);
border-radius: var(--kole-radius-base);
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: var(--kole-m-font-size-label);
cursor: pointer;
touch-action: manipulation;
}
.kole-m-cascader__trigger:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: 2px;
}
frameworks-mobile/Cascader.html · H5 原生(无框架) · 319 行
<!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 · Cascader(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Cascader.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); }
/* 展示框:transform 建立包含块,遮罩与浮层限定在框内(生产环境落在视口上) */
.demo-frame { position: relative; max-width: 375px; margin: 0 auto; overflow: hidden;
transform: translateZ(0); background: var(--kole-color-page-bg);
border-block: 1px solid var(--kole-color-border); }
.demo-frame--tall { height: 320px; }
.demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
<section class="demo-block" data-demo="levels">
<p class="demo-label">逐级下钻(点省份 → 下一级换成城市;每级只渲染当前一层,避免 375px 下横向挤压)</p>
<div class="kole-m-cascader kole-m-cascader--panel" id="cas-levels" data-assert="cascader-levels"
data-path="" data-level="0" data-value="">
<div class="kole-m-cascader__path" hidden></div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="省份">
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="zj"
data-behavior="click-sets-attr:#cas-levels|data-level|1">
<span class="kole-m-cascader__label">浙江省</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="gd">
<span class="kole-m-cascader__label">广东省</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
</ul>
</div>
</section>
<section class="demo-block" data-demo="popup">
<p class="demo-label">底部浮层(mode=popup:默认收起,遮罩点击关闭;标题读屏可读)</p>
<div class="demo-frame demo-frame--tall" data-assert="cascader-popup">
<div class="kole-m-cascader__mask is-open" aria-hidden="true"></div>
<div class="kole-m-cascader kole-m-cascader--popup kole-m-cascader--round is-open" id="cas-popup"
role="dialog" aria-modal="true" aria-labelledby="cas-popup-title" data-level="2" data-value="zj/hz">
<div class="kole-m-cascader__header">
<button class="kole-m-cascader__btn" type="button" id="cas-popup-cancel"
data-behavior="click-sets-attr:#cas-popup|data-cancelled|true">取消</button>
<span class="kole-m-cascader__title" id="cas-popup-title">选择地区</span>
<button class="kole-m-cascader__btn kole-m-cascader__btn--confirm" type="button" id="cas-popup-confirm"
data-behavior="click-sets-attr:#cas-popup|data-confirmed|true">确定</button>
</div>
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item" type="button" data-depth="0">浙江省</button>
<span class="kole-m-cascader__sep" aria-hidden="true">›</span>
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="1">杭州市</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="区县">
<li class="kole-m-cascader__option is-selected" role="option" aria-selected="true" data-value="xh">
<span class="kole-m-cascader__label">西湖区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="bj">
<span class="kole-m-cascader__label">滨江区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="yh">
<span class="kole-m-cascader__label">余杭区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
</ul>
</div>
</div>
</section>
<section class="demo-block" data-demo="back">
<p class="demo-label">回退上一级(点路径条里的上级即退回该级;末位是「你在这里」的锚点,不可点)</p>
<div class="kole-m-cascader kole-m-cascader--panel kole-m-cascader--round" id="cas-back" data-assert="cascader-back"
data-path="zj,hz" data-level="2" data-value="zj/hz">
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item" type="button" data-depth="0"
data-behavior="click-sets-attr:#cas-back|data-level|1">浙江省</button>
<span class="kole-m-cascader__sep" aria-hidden="true">›</span>
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="1">杭州市</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="区县">
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="xh">
<span class="kole-m-cascader__label">西湖区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="bj">
<span class="kole-m-cascader__label">滨江区</span><span class="kole-m-cascader__check" aria-hidden="true">✓</span>
</li>
</ul>
</div>
</section>
<section class="demo-block" data-demo="states">
<p class="demo-label">禁用项与空态(禁用行置灰并写 aria-disabled,点击不响应;该级无子项时显示占位)</p>
<div class="kole-m-cascader kole-m-cascader--panel" id="cas-states" data-assert="cascader-states"
data-path="gd" data-level="1" data-value="gd">
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="0">广东省</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="城市">
<li class="kole-m-cascader__option" role="option" aria-selected="false" data-value="gz">
<span class="kole-m-cascader__label">广州市</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
<li class="kole-m-cascader__option is-disabled" role="option" aria-selected="false" aria-disabled="true" data-value="sz">
<span class="kole-m-cascader__label">深圳市(暂未开放)</span><span class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
</ul>
</div>
<div class="kole-m-cascader kole-m-cascader--panel" data-assert="cascader-empty"
data-path="gd,sz" data-level="2" data-value="gd/sz">
<div class="kole-m-cascader__path">
<button class="kole-m-cascader__path-item" type="button" data-depth="0">广东省</button>
<span class="kole-m-cascader__sep" aria-hidden="true">›</span>
<button class="kole-m-cascader__path-item is-current" type="button" aria-current="true" data-depth="1">深圳市</button>
</div>
<ul class="kole-m-cascader__panel" role="listbox" aria-label="区县"></ul>
<p class="kole-m-cascader__empty">该市暂无开通区县</p>
</div>
</section>
</div>
<script>
/* 演示页交互:真实的「逐级下钻 + 回退」。
级数不写死:路径条按已选深度渲染,面板只渲染**当前一层**。
每块各自独立:初始路径读自本块的 data-path,状态只落在本块 DOM 上。 */
(function () {
/* 省市区示例树(演示数据,不是组件契约的一部分) */
var TREE = [
{ label: '浙江省', value: 'zj', children: [
{ label: '杭州市', value: 'hz', children: [
{ label: '西湖区', value: 'xh' }, { label: '滨江区', value: 'bj' }, { label: '余杭区', value: 'yh' }
] },
{ label: '宁波市', value: 'nb', children: [
{ label: '海曙区', value: 'hs' }, { label: '鄞州区', value: 'yz' }
] }
] },
{ label: '广东省', value: 'gd', children: [
{ label: '广州市', value: 'gz', children: [
{ label: '天河区', value: 'th' }, { label: '海珠区', value: 'hz2' }
] },
{ label: '深圳市', value: 'sz', children: [] }
] }
];
var LAYER_LABELS = ['省份', '城市', '区县'];
function find(list, val) {
for (var i = 0; i < list.length; i++) {
if (list[i] && String(list[i].value) === String(val)) return list[i];
}
return null;
}
/* 沿路径走到第 depth 级的选项数组(depth=0 是根) */
function levelList(path, depth) {
var list = TREE;
for (var i = 0; i < depth; i++) {
var hit = find(list, path[i]);
if (!hit || !hit.children || !hit.children.length) return [];
list = hit.children;
}
return list;
}
/* 当前展示层级:末项是叶子时停在它所在层(可改选同级),否则展示下一级 */
function displayDepth(path) {
var depth = path.length;
if (depth > 0) {
var last = find(levelList(path, depth - 1), path[depth - 1]);
if (last && (!last.children || !last.children.length)) depth = depth - 1;
}
return depth;
}
function pathItems(path) {
var out = [];
var list = TREE;
for (var i = 0; i < path.length; i++) {
var hit = find(list, path[i]);
if (!hit) break;
out.push(hit);
list = hit.children || [];
}
return out;
}
function stateOf(root) {
if (!root.__koleCasPath) {
var raw = (root.getAttribute('data-path') || '').trim();
root.__koleCasPath = raw ? raw.split(',') : [];
}
return root.__koleCasPath;
}
function render(root) {
var path = stateOf(root);
var depth = displayDepth(path);
var list = levelList(path, depth);
var pickedValue = path[depth];
var ul = root.querySelector('.kole-m-cascader__panel');
var bar = root.querySelector('.kole-m-cascader__path');
if (!ul) return;
/* 面板:只渲染当前一层 */
ul.setAttribute('aria-label', LAYER_LABELS[depth] || '选项');
var html = '';
list.forEach(function (node) {
var isLeaf = !node.children || !node.children.length;
var on = String(node.value) === String(pickedValue === undefined ? '' : pickedValue);
html += '<li class="kole-m-cascader__option' + (on ? ' is-selected' : '') + '"' +
' role="option" aria-selected="' + (on ? 'true' : 'false') + '"' +
' data-value="' + node.value + '">' +
'<span class="kole-m-cascader__label">' + node.label + '</span>' +
(isLeaf
? '<span class="kole-m-cascader__check" aria-hidden="true">✓</span>'
: '<span class="kole-m-cascader__arrow" aria-hidden="true">›</span>') +
'</li>';
});
ul.innerHTML = html;
/* 路径条:非末位可点(回退到该级),末位是「你在这里」 */
if (bar) {
var items = pathItems(path);
if (!items.length) {
bar.hidden = true;
bar.innerHTML = '';
} else {
bar.hidden = false;
var parts = [];
items.forEach(function (node, i) {
var isLast = i === items.length - 1;
parts.push('<button class="kole-m-cascader__path-item' + (isLast ? ' is-current' : '') + '"' +
' type="button" data-depth="' + i + '"' + (isLast ? ' aria-current="true"' : '') + '>' +
node.label + '</button>');
if (!isLast) parts.push('<span class="kole-m-cascader__sep" aria-hidden="true">›</span>');
});
bar.innerHTML = parts.join('');
}
}
root.setAttribute('data-level', String(depth));
root.setAttribute('data-value', path.join('/'));
}
/* 点选项:截断更深的层级,写入本级选中值 */
function pick(root, depth, value) {
if (root.classList.contains('is-disabled')) return;
var path = stateOf(root).slice(0, depth);
path[depth] = String(value);
root.__koleCasPath = path;
render(root);
}
/* 点路径条上的上级:退回该级(该级自身保留,重新展示它的下一级) */
function back(root, depth) {
root.__koleCasPath = stateOf(root).slice(0, depth + 1);
render(root);
}
Array.prototype.forEach.call(document.querySelectorAll('.kole-m-cascader'), function (root) {
root.addEventListener('click', function (e) {
var hit = e.target.closest ? e.target.closest('.kole-m-cascader__path-item') : null;
if (hit && root.contains(hit)) {
if (hit.classList.contains('is-current')) return; /* 末位不响应 */
back(root, Number(hit.getAttribute('data-depth')) || 0);
return;
}
var option = e.target.closest ? e.target.closest('.kole-m-cascader__option') : null;
if (!option || !root.contains(option) || option.classList.contains('is-disabled')) return;
if (option.getAttribute('aria-disabled') === 'true') return;
var ul = option.parentNode;
var depth = Number(root.getAttribute('data-level')) || 0;
var idx = Array.prototype.indexOf.call(ul.children, option);
pick(root, depth, option.getAttribute('data-value') || String(idx));
});
});
/* 浮层的取消 / 确定只打标记(真实业务里由宿主关闭浮层并决定是否采纳) */
var cancel = document.getElementById('cas-popup-cancel');
var confirm = document.getElementById('cas-popup-confirm');
var popup = document.getElementById('cas-popup');
if (cancel && popup) {
cancel.addEventListener('click', function () { popup.setAttribute('data-cancelled', 'true'); });
}
if (confirm && popup) {
confirm.addEventListener('click', function () { popup.setAttribute('data-confirmed', 'true'); });
}
/* 初始渲染只对齐 data-level / data-value,不改写服务端已渲染好的静态列表 */
Array.prototype.forEach.call(document.querySelectorAll('.kole-m-cascader'), function (root) {
var path = stateOf(root);
root.setAttribute('data-value', path.join('/'));
root.setAttribute('data-level', String(displayDepth(path)));
});
})();
</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/Cascader.jsx · React · 241 行
import React, { useState } from 'react';
import './Cascader.css';
/* 级联选择器(移动端)— 规格 §44。
多级联动:点某一级后下一级选项变化;点路径条里的上级可回退(规格 §44.5)。
级数不写死:路径条按已选深度渲染,面板**只渲染当前一层** —— 375px 宽下并排多列会把
每列压到 100px 以内,无法阅读(规格 §44.2)。
路径的当前位置以 data-level 暴露,选中项以 is-selected + aria-selected 双通道表达。
本端只回传下一段路径(change),确认由宿主决定是否采纳。 */
/* 选项可以是字符串,也可以是 { label, value, disabled, children } 结点(规格 §44.7) */
function nodeOf(option, i) {
if (option === null || option === undefined) return { label: '', value: String(i) };
if (typeof option === 'object') {
return {
label: String(option.label === undefined ? '' : option.label),
value: String(option.value === undefined ? i : option.value),
disabled: !!option.disabled,
children: Array.isArray(option.children) ? option.children : []
};
}
return { label: String(option), value: String(option) };
}
function normTree(options) {
return (Array.isArray(options) ? options : []).map(nodeOf);
}
/* 沿 path 走到第 depth 级的选项数组(depth=0 是根) */
function levelList(tree, path, depth) {
let list = tree;
for (let i = 0; i < depth; i++) {
const hit = list.find((n) => n.value === String(path[i]));
if (!hit || !hit.children.length) return [];
list = hit.children;
}
return list;
}
/* 当前展示层级:末项是叶子时停在它所在层(可改选同级),否则展示下一级 */
function displayDepth(tree, path) {
let depth = path.length;
if (depth > 0) {
const parent = levelList(tree, path, depth - 1);
const last = parent.find((n) => n.value === String(path[depth - 1]));
if (last && !last.children.length) depth -= 1;
}
return depth;
}
/* 路径条:已选各级的结点(用于渲染可点回的按钮) */
function pathItems(tree, path) {
const out = [];
let list = tree;
for (let i = 0; i < path.length; i++) {
const hit = list.find((n) => n.value === String(path[i]));
if (!hit) break;
out.push(hit);
list = hit.children;
}
return out;
}
const LAYER_LABELS = ['省份', '城市', '区县'];
export default function Cascader({
mode = 'panel',
round = false,
showPath = true,
open = false,
closeOnMask = true,
value = [],
options = [],
disabled = false,
title = '',
label = '级联选项',
onChange,
onConfirm,
onClose,
children = null,
}) {
const tree = normTree(options);
const path = Array.isArray(value) ? value.map(String) : [];
/* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
const [draft, setDraft] = useState(null);
const picked = draft || path;
const depth = displayDepth(tree, picked);
const list = levelList(tree, picked, depth);
const pickedValue = picked[depth];
const items = pathItems(tree, picked);
const total = items.length;
const cls =
'kole-m-cascader' +
` kole-m-cascader--${mode === 'popup' ? 'popup' : 'panel'}` +
(showPath ? '' : ' kole-m-cascader--no-path') +
(round ? ' kole-m-cascader--round' : '') +
(open ? ' is-open' : '') +
(disabled ? ' is-disabled' : '');
function emitPath(next) {
setDraft(next);
if (onChange) onChange(next);
}
/* 点选项:截断更深的层级,写入本级选中值(规格 §44.5) */
function pick(node) {
if (disabled || node.disabled) return;
const next = picked.slice(0, depth);
next[depth] = node.value;
emitPath(next);
}
/* 点路径条上的上级:退回该级,重新展示它的下一级 */
function back(d) {
if (disabled) return;
emitPath(picked.slice(0, d + 1));
}
const pathEl = showPath ? (
<div className="kole-m-cascader__path">
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<React.Fragment key={item.value + i}>
<button
className={'kole-m-cascader__path-item' + (isLast ? ' is-current' : '')}
type="button"
aria-current={isLast ? 'true' : undefined}
disabled={isLast}
onClick={() => back(i)}
>
{item.label}
</button>
{isLast ? null : (
<span className="kole-m-cascader__sep" aria-hidden="true">
›
</span>
)}
</React.Fragment>
);
})}
</div>
) : null;
const panelEl =
list.length > 0 ? (
<ul
className="kole-m-cascader__panel"
role="listbox"
aria-label={label + ' · ' + (LAYER_LABELS[depth] || '第 ' + (depth + 1) + ' 级')}
>
{list.map((node, i) => {
const selected = String(node.value) === String(pickedValue === undefined ? '' : pickedValue);
const off = disabled || node.disabled;
const isLeaf = node.children.length === 0;
return (
<li
className={
'kole-m-cascader__option' + (selected ? ' is-selected' : '') + (off ? ' is-disabled' : '')
}
role="option"
aria-selected={selected ? 'true' : 'false'}
aria-disabled={off ? 'true' : undefined}
key={node.value + i}
onClick={() => pick(node)}
>
<span className="kole-m-cascader__label">{node.label}</span>
{isLeaf ? (
<span className="kole-m-cascader__check" aria-hidden="true">
✓
</span>
) : (
<span className="kole-m-cascader__arrow" aria-hidden="true">
›
</span>
)}
</li>
);
})}
</ul>
) : (
<p className="kole-m-cascader__empty">该级暂无可选项</p>
);
if (mode === 'popup') {
return (
<>
{children}
<div
className={'kole-m-cascader__mask' + (open ? ' is-open' : '')}
aria-hidden="true"
onClick={() => {
if (closeOnMask && onClose) onClose();
}}
/>
<div
className={cls}
role="dialog"
aria-modal="true"
aria-label={title || '选择地区'}
aria-hidden={open ? undefined : 'true'}
>
<div className="kole-m-cascader__header">
<button
className="kole-m-cascader__btn"
type="button"
onClick={() => {
if (onClose) onClose();
}}
>
取消
</button>
<span className="kole-m-cascader__title">{title || '选择地区'}</span>
<button
className="kole-m-cascader__btn kole-m-cascader__btn--confirm"
type="button"
onClick={() => {
if (!disabled && onConfirm) onConfirm(picked);
}}
>
确定
</button>
</div>
{pathEl}
{panelEl}
</div>
</>
);
}
return (
<div className={cls} role="group" aria-label={label} data-level={depth}>
{pathEl}
{panelEl}
{children}
</div>
);
}
frameworks-mobile/Cascader.vue2.vue · Vue 2 · 234 行
<template>
<div>
<slot></slot>
<div
v-if="mode === 'popup'"
class="kole-m-cascader__mask"
:class="{ 'is-open': open }"
aria-hidden="true"
@click="onMaskClick"
></div>
<div
class="kole-m-cascader"
:class="panelClass"
:role="mode === 'popup' ? 'dialog' : 'group'"
:aria-modal="mode === 'popup' ? 'true' : null"
:aria-label="mode === 'popup' ? (title || '选择地区') : label"
:aria-hidden="mode === 'popup' && !open ? 'true' : null"
:data-level="depth"
>
<div v-if="mode === 'popup'" class="kole-m-cascader__header">
<button class="kole-m-cascader__btn" type="button" @click="onCancelClick">取消</button>
<span class="kole-m-cascader__title">{{ title || '选择地区' }}</span>
<button
class="kole-m-cascader__btn kole-m-cascader__btn--confirm"
type="button"
@click="onConfirmClick"
>确定</button>
</div>
<div v-if="showPath" class="kole-m-cascader__path">
<template v-for="(item, i) in items">
<button
:key="'p' + i"
class="kole-m-cascader__path-item"
:class="{ 'is-current': i === items.length - 1 }"
type="button"
:aria-current="i === items.length - 1 ? 'true' : null"
:disabled="i === items.length - 1"
@click="back(i)"
>{{ item.label }}</button>
<span v-if="i !== items.length - 1" :key="'s' + i" class="kole-m-cascader__sep" aria-hidden="true">›</span>
</template>
</div>
<ul v-if="list.length" class="kole-m-cascader__panel" role="listbox" :aria-label="listLabel">
<li
v-for="(node, i) in list"
:key="node.value + '-' + i"
class="kole-m-cascader__option"
:class="{ 'is-selected': isSelected(node), 'is-disabled': isOff(node) }"
role="option"
:aria-selected="isSelected(node) ? 'true' : 'false'"
:aria-disabled="isOff(node) ? 'true' : null"
@click="pick(node)"
>
<span class="kole-m-cascader__label">{{ node.label }}</span>
<span v-if="isLeaf(node)" class="kole-m-cascader__check" aria-hidden="true">✓</span>
<span v-else class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
</ul>
<p v-else class="kole-m-cascader__empty">该级暂无可选项</p>
</div>
</div>
</template>
<script>
/* 级联选择器(移动端)— 规格 §44。
多级联动:点某一级后下一级选项变化;点路径条里的上级可回退(规格 §44.5)。
级数不写死:路径条按已选深度渲染,面板**只渲染当前一层** —— 375px 宽下并排多列会把
每列压到 100px 以内,无法阅读(规格 §44.2)。
变动只回传下一段路径(change),确认由宿主决定是否采纳。 */
var LAYER_LABELS = ['省份', '城市', '区县'];
/* 选项可以是字符串,也可以是 { label, value, disabled, children } 结点(规格 §44.7) */
function nodeOf(option, i) {
if (option === null || option === undefined) {
return { label: '', value: String(i), disabled: false, children: [] };
}
if (typeof option === 'object') {
return {
label: String(option.label === undefined ? '' : option.label),
value: String(option.value === undefined ? i : option.value),
disabled: !!option.disabled,
children: Array.isArray(option.children) ? option.children.map(nodeOf) : []
};
}
return { label: String(option), value: String(option), disabled: false, children: [] };
}
function normTree(options) {
return (Array.isArray(options) ? options : []).map(nodeOf);
}
/* 沿 path 走到第 depth 级的选项数组(depth=0 是根) */
function levelList(tree, path, depth) {
var list = tree;
for (var i = 0; i < depth; i++) {
var hit = null;
for (var k = 0; k < list.length; k++) {
if (list[k].value === String(path[i])) { hit = list[k]; break; }
}
if (!hit || !hit.children.length) return [];
list = hit.children;
}
return list;
}
/* 当前展示层级:末项是叶子时停在它所在层(可改选同级),否则展示下一级 */
function displayDepth(tree, path) {
var depth = path.length;
if (depth > 0) {
var parent = levelList(tree, path, depth - 1);
var last = null;
for (var k = 0; k < parent.length; k++) {
if (parent[k].value === String(path[depth - 1])) { last = parent[k]; break; }
}
if (last && !last.children.length) depth -= 1;
}
return depth;
}
/* 路径条:已选各级的结点(用于渲染可点回的按钮) */
function pathItems(tree, path) {
var out = [];
var list = tree;
for (var i = 0; i < path.length; i++) {
var hit = null;
for (var k = 0; k < list.length; k++) {
if (list[k].value === String(path[i])) { hit = list[k]; break; }
}
if (!hit) break;
out.push(hit);
list = hit.children;
}
return out;
}
export default {
name: 'KoleMCascader',
props: {
mode: { type: String, default: 'panel' },
round: { type: Boolean, default: false },
showPath: { type: Boolean, default: true },
open: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true },
value: { type: Array, default: function () { return []; } },
options: { type: Array, default: function () { return []; } },
disabled: { type: Boolean, default: false },
title: { type: String, default: '' },
label: { type: String, default: '级联选项' }
},
data: function () {
return {
LAYER_LABELS: LAYER_LABELS,
/* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
draft: null
};
},
computed: {
tree: function () {
return normTree(this.options);
},
picked: function () {
return this.draft || (Array.isArray(this.value) ? this.value.map(String) : []);
},
depth: function () {
return displayDepth(this.tree, this.picked);
},
list: function () {
return levelList(this.tree, this.picked, this.depth);
},
pickedValue: function () {
return this.picked[this.depth];
},
items: function () {
return pathItems(this.tree, this.picked);
},
listLabel: function () {
return this.label + ' · ' + (LAYER_LABELS[this.depth] || '第 ' + (this.depth + 1) + ' 级');
},
panelClass: function () {
return [
'kole-m-cascader--' + (this.mode === 'popup' ? 'popup' : 'panel'),
this.showPath ? '' : 'kole-m-cascader--no-path',
this.round ? 'kole-m-cascader--round' : '',
this.open ? 'is-open' : '',
this.disabled ? 'is-disabled' : ''
].filter(Boolean);
}
},
methods: {
isSelected: function (node) {
return String(node.value) === String(this.pickedValue === undefined ? '' : this.pickedValue);
},
isOff: function (node) {
return this.disabled || node.disabled;
},
isLeaf: function (node) {
return !node.children.length;
},
emitPath: function (next) {
this.draft = next;
this.$emit('change', next);
},
/* 点选项:截断更深的层级,写入本级选中值(规格 §44.5) */
pick: function (node) {
if (this.isOff(node)) return;
var next = this.picked.slice(0, this.depth);
next[this.depth] = node.value;
this.emitPath(next);
},
/* 点路径条上的上级:退回该级,重新展示它的下一级 */
back: function (d) {
if (this.disabled) return;
this.emitPath(this.picked.slice(0, d + 1));
},
onMaskClick: function () {
if (!this.closeOnMask) return;
this.$emit('close');
},
onCancelClick: function () {
this.$emit('close');
},
onConfirmClick: function () {
if (this.disabled) return;
this.$emit('confirm', this.picked);
}
}
};
</script>
<style src="./Cascader.css"></style>
frameworks-mobile/Cascader.vue3.vue · Vue 3 · 201 行
<template>
<div>
<slot></slot>
<div
v-if="mode === 'popup'"
class="kole-m-cascader__mask"
:class="{ 'is-open': open }"
aria-hidden="true"
@click="onMaskClick"
></div>
<div
class="kole-m-cascader"
:class="panelClass"
:role="mode === 'popup' ? 'dialog' : 'group'"
:aria-modal="mode === 'popup' ? 'true' : null"
:aria-label="mode === 'popup' ? (title || '选择地区') : label"
:aria-hidden="mode === 'popup' && !open ? 'true' : null"
:data-level="depth"
>
<div v-if="mode === 'popup'" class="kole-m-cascader__header">
<button class="kole-m-cascader__btn" type="button" @click="onCancelClick">取消</button>
<span class="kole-m-cascader__title">{{ title || '选择地区' }}</span>
<button
class="kole-m-cascader__btn kole-m-cascader__btn--confirm"
type="button"
@click="onConfirmClick"
>确定</button>
</div>
<div v-if="showPath" class="kole-m-cascader__path">
<template v-for="(item, i) in items" :key="item.value + '-' + i">
<button
class="kole-m-cascader__path-item"
:class="{ 'is-current': i === items.length - 1 }"
type="button"
:aria-current="i === items.length - 1 ? 'true' : null"
:disabled="i === items.length - 1"
@click="back(i)"
>{{ item.label }}</button>
<span v-if="i !== items.length - 1" class="kole-m-cascader__sep" aria-hidden="true">›</span>
</template>
</div>
<ul v-if="list.length" class="kole-m-cascader__panel" role="listbox" :aria-label="listLabel">
<li
v-for="(node, i) in list"
:key="node.value + '-' + i"
class="kole-m-cascader__option"
:class="{ 'is-selected': isSelected(node), 'is-disabled': isOff(node) }"
role="option"
:aria-selected="isSelected(node) ? 'true' : 'false'"
:aria-disabled="isOff(node) ? 'true' : null"
@click="pick(node)"
>
<span class="kole-m-cascader__label">{{ node.label }}</span>
<span v-if="isLeaf(node)" class="kole-m-cascader__check" aria-hidden="true">✓</span>
<span v-else class="kole-m-cascader__arrow" aria-hidden="true">›</span>
</li>
</ul>
<p v-else class="kole-m-cascader__empty">该级暂无可选项</p>
</div>
</div>
</template>
<script setup>
/* 级联选择器(移动端)— 规格 §44。
多级联动:点某一级后下一级选项变化;点路径条里的上级可回退(规格 §44.5)。
级数不写死:路径条按已选深度渲染,面板**只渲染当前一层** —— 375px 宽下并排多列会把
每列压到 100px 以内,无法阅读(规格 §44.2)。
变动只回传下一段路径(change),确认由宿主决定是否采纳。 */
import { computed, ref } from 'vue';
const props = defineProps({
mode: { type: String, default: 'panel' },
round: { type: Boolean, default: false },
showPath: { type: Boolean, default: true },
open: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true },
value: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
title: { type: String, default: '' },
label: { type: String, default: '级联选项' }
});
const emit = defineEmits(['change', 'confirm', 'close']);
const LAYER_LABELS = ['省份', '城市', '区县'];
/* 选项可以是字符串,也可以是 { label, value, disabled, children } 结点(规格 §44.7) */
function nodeOf(option, i) {
if (option === null || option === undefined) return { label: '', value: String(i), disabled: false, children: [] };
if (typeof option === 'object') {
return {
label: String(option.label === undefined ? '' : option.label),
value: String(option.value === undefined ? i : option.value),
disabled: !!option.disabled,
children: Array.isArray(option.children) ? option.children.map(nodeOf) : []
};
}
return { label: String(option), value: String(option), disabled: false, children: [] };
}
const tree = computed(() => (Array.isArray(props.options) ? props.options : []).map(nodeOf));
/* 沿 path 走到第 depth 级的选项数组(depth=0 是根) */
function levelList(path, depth) {
let list = tree.value;
for (let i = 0; i < depth; i++) {
const hit = list.find((n) => n.value === String(path[i]));
if (!hit || !hit.children.length) return [];
list = hit.children;
}
return list;
}
/* 当前展示层级:末项是叶子时停在它所在层(可改选同级),否则展示下一级 */
function displayDepth(path) {
let depth = path.length;
if (depth > 0) {
const parent = levelList(path, depth - 1);
const last = parent.find((n) => n.value === String(path[depth - 1]));
if (last && !last.children.length) depth -= 1;
}
return depth;
}
/* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
const draft = ref(null);
const picked = computed(() => draft.value || (Array.isArray(props.value) ? props.value.map(String) : []));
const depth = computed(() => displayDepth(picked.value));
const list = computed(() => levelList(picked.value, depth.value));
const pickedValue = computed(() => picked.value[depth.value]);
const items = computed(() => {
const out = [];
let lv = tree.value;
for (let i = 0; i < picked.value.length; i++) {
const hit = lv.find((n) => n.value === String(picked.value[i]));
if (!hit) break;
out.push(hit);
lv = hit.children;
}
return out;
});
const listLabel = computed(() => props.label + ' · ' + (LAYER_LABELS[depth.value] || '第 ' + (depth.value + 1) + ' 级'));
const panelClass = computed(() => [
'kole-m-cascader--' + (props.mode === 'popup' ? 'popup' : 'panel'),
props.showPath ? '' : 'kole-m-cascader--no-path',
props.round ? 'kole-m-cascader--round' : '',
props.open ? 'is-open' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function isSelected(node) {
return String(node.value) === String(pickedValue.value === undefined ? '' : pickedValue.value);
}
function isOff(node) {
return props.disabled || node.disabled;
}
function isLeaf(node) {
return !node.children.length;
}
function emitPath(next) {
draft.value = next;
emit('change', next);
}
/* 点选项:截断更深的层级,写入本级选中值(规格 §44.5) */
function pick(node) {
if (isOff(node)) return;
const next = picked.value.slice(0, depth.value);
next[depth.value] = node.value;
emitPath(next);
}
/* 点路径条上的上级:退回该级,重新展示它的下一级 */
function back(d) {
if (props.disabled) return;
emitPath(picked.value.slice(0, d + 1));
}
function onMaskClick() {
if (!props.closeOnMask) return;
emit('close');
}
function onCancelClick() {
emit('close');
}
function onConfirmClick() {
if (props.disabled) return;
emit('confirm', picked.value);
}
</script>
<style src="./Cascader.css"></style>
frameworks-mobile/Cascader.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 360 行
<template>
<view>
<slot></slot>
<view
v-if="mode === 'popup'"
class="kole-m-cascader__mask"
:class="{ 'is-open': open }"
aria-hidden="true"
@tap="onMaskTap"
@touchmove.stop.prevent
></view>
<view
class="kole-m-cascader"
:class="panelClass"
:role="mode === 'popup' ? 'dialog' : 'group'"
:aria-modal="mode === 'popup' ? 'true' : 'false'"
:aria-label="mode === 'popup' ? (title || '选择地区') : label"
:aria-hidden="mode === 'popup' && !open ? 'true' : 'false'"
:data-level="String(depth)"
>
<view v-if="mode === 'popup'" class="kole-m-cascader__header">
<view class="kole-m-cascader__btn" role="button" @tap="onCancelTap"><text>取消</text></view>
<text class="kole-m-cascader__title">{{ title || '选择地区' }}</text>
<view class="kole-m-cascader__btn kole-m-cascader__btn--confirm" role="button" @tap="onConfirmTap">
<text>确定</text>
</view>
</view>
<view v-if="showPath" class="kole-m-cascader__path">
<template v-for="(item, i) in items" :key="item.value + '-' + i">
<view
class="kole-m-cascader__path-item"
:class="{ 'is-current': i === items.length - 1 }"
:role="i === items.length - 1 ? '' : 'button'"
:aria-current="i === items.length - 1 ? 'true' : 'false'"
@tap="back(i)"
>
<text>{{ item.label }}</text>
</view>
<text v-if="i !== items.length - 1" class="kole-m-cascader__sep">›</text>
</template>
</view>
<scroll-view v-if="list.length" class="kole-m-cascader__panel" scroll-y :role="'listbox'" :aria-label="listLabel">
<view
v-for="(node, i) in list"
:key="node.value + '-' + i"
class="kole-m-cascader__option"
:class="isSelected(node) ? 'is-selected' : (isOff(node) ? 'is-disabled' : '')"
:role="'option'"
:aria-selected="isSelected(node) ? 'true' : 'false'"
:aria-disabled="isOff(node) ? 'true' : 'false'"
@tap="pick(node)"
>
<text class="kole-m-cascader__label">{{ node.label }}</text>
<text v-if="isLeaf(node)" class="kole-m-cascader__check">✓</text>
<text v-else class="kole-m-cascader__arrow">›</text>
</view>
</scroll-view>
<text v-else class="kole-m-cascader__empty">该级暂无可选项</text>
</view>
</view>
</template>
<script setup>
/* uni-app 端 · 级联选择器(移动端)— 规格 §44
跨端差异:用 view / text;选项区用 scroll-view(scroll-y)代替 ul / li,滚动由小程序/App 原生接管,
role / aria-* 仍按规格 §44.6 保留(用 :role / :aria-* 绑定,H5 产物里生效)。
点击一律 @tap;浮层遮罩用 @touchmove.stop.prevent 阻止背景滚动穿透。
级数不写死:路径条按已选深度渲染,面板**只渲染当前一层** —— 375px 宽下并排多列会把每列压到
100px 以内,无法阅读(规格 §44.2)。
尺寸用 rpx(88rpx = 375pt 下的 44px 触控最小边长)。 */
import { computed, ref } from 'vue';
const props = defineProps({
mode: { type: String, default: 'panel' },
round: { type: Boolean, default: false },
showPath: { type: Boolean, default: true },
open: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true },
value: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
title: { type: String, default: '' },
label: { type: String, default: '级联选项' }
});
const emit = defineEmits(['change', 'confirm', 'close']);
const LAYER_LABELS = ['省份', '城市', '区县'];
/* 选项可以是字符串,也可以是 { label, value, disabled, children } 结点(规格 §44.7) */
function nodeOf(option, i) {
if (option === null || option === undefined) {
return { label: '', value: String(i), disabled: false, children: [] };
}
if (typeof option === 'object') {
return {
label: String(option.label === undefined ? '' : option.label),
value: String(option.value === undefined ? i : option.value),
disabled: !!option.disabled,
children: Array.isArray(option.children) ? option.children.map(nodeOf) : []
};
}
return { label: String(option), value: String(option), disabled: false, children: [] };
}
const tree = computed(() => (Array.isArray(props.options) ? props.options : []).map(nodeOf));
/* 沿 path 走到第 depth 级的选项数组(depth=0 是根) */
function levelList(path, depth) {
let list = tree.value;
for (let i = 0; i < depth; i++) {
const hit = list.find((n) => n.value === String(path[i]));
if (!hit || !hit.children.length) return [];
list = hit.children;
}
return list;
}
/* 当前展示层级:末项是叶子时停在它所在层(可改选同级),否则展示下一级 */
function displayDepth(path) {
let depth = path.length;
if (depth > 0) {
const parent = levelList(path, depth - 1);
const last = parent.find((n) => n.value === String(path[depth - 1]));
if (last && !last.children.length) depth -= 1;
}
return depth;
}
/* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
const draft = ref(null);
const picked = computed(() => draft.value || (Array.isArray(props.value) ? props.value.map(String) : []));
const depth = computed(() => displayDepth(picked.value));
const list = computed(() => levelList(picked.value, depth.value));
const pickedValue = computed(() => picked.value[depth.value]);
const items = computed(() => {
const out = [];
let lv = tree.value;
for (let i = 0; i < picked.value.length; i++) {
const hit = lv.find((n) => n.value === String(picked.value[i]));
if (!hit) break;
out.push(hit);
lv = hit.children;
}
return out;
});
const listLabel = computed(
() => props.label + ' · ' + (LAYER_LABELS[depth.value] || '第 ' + (depth.value + 1) + ' 级')
);
const panelClass = computed(() => [
'kole-m-cascader--' + (props.mode === 'popup' ? 'popup' : 'panel'),
props.showPath ? '' : 'kole-m-cascader--no-path',
props.round ? 'kole-m-cascader--round' : '',
props.open ? 'is-open' : ''
].filter(Boolean));
function isSelected(node) {
return String(node.value) === String(pickedValue.value === undefined ? '' : pickedValue.value);
}
function isOff(node) {
return props.disabled || node.disabled;
}
function isLeaf(node) {
return !node.children.length;
}
function emitPath(next) {
draft.value = next;
emit('change', next);
}
/* 点选项:截断更深的层级,写入本级选中值(规格 §44.5) */
function pick(node) {
if (isOff(node)) return;
const next = picked.value.slice(0, depth.value);
next[depth.value] = node.value;
emitPath(next);
}
/* 点路径条上的上级:退回该级,重新展示它的下一级 */
function back(d) {
if (props.disabled) return;
if (d === items.value.length - 1) return; /* 末位是「你在这里」的锚点,不响应 */
emitPath(picked.value.slice(0, d + 1));
}
function onMaskTap() {
if (!props.closeOnMask) return;
emit('close');
}
function onCancelTap() {
emit('close');
}
function onConfirmTap() {
if (props.disabled) return;
emit('confirm', picked.value);
}
</script>
<style>
.kole-m-cascader {
--kole-m-cascader-row-height: 88rpx;
--kole-m-cascader-panel-height: 480rpx;
--kole-m-touch-target: 88rpx;
--kole-m-font-size-body: 32rpx;
--kole-m-font-size-label: 28rpx;
--kole-m-font-size-caption: 22rpx;
--kole-m-gutter: 32rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
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-cascader__mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
background-color: var(--kole-color-mask);
opacity: 0;
}
.kole-m-cascader__mask.is-open { opacity: 1; }
/* 变体 mode=panel:内嵌在页面里,跟随文档流 */
.kole-m-cascader--panel {
border: 1rpx solid var(--kole-color-border);
border-radius: 8rpx;
}
/* 变体 mode=popup:底部浮层(默认收起,靠 is-open 滑入) */
.kole-m-cascader--popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2001;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
/* 变体 showPath=false:不显示路径条 */
.kole-m-cascader--no-path .kole-m-cascader__path { display: none; }
/* 变体 round=true:浮层靠内容一侧切圆角 */
.kole-m-cascader--round {
border-top-left-radius: 16rpx;
border-top-right-radius: 16rpx;
}
.kole-m-cascader__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx var(--kole-m-gutter);
border-bottom: 1rpx solid var(--kole-color-border);
}
.kole-m-cascader__title {
color: var(--kole-color-text-title);
font-size: var(--kole-m-font-size-body);
}
.kole-m-cascader__btn {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: var(--kole-m-touch-target);
padding: 0 16rpx;
color: var(--kole-color-brand);
font-size: var(--kole-m-font-size-label);
}
.kole-m-cascader__btn--confirm { font-weight: 500; }
/* 路径条:显示已选层级,点某一级回退到该级 */
.kole-m-cascader__path {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 8rpx var(--kole-m-gutter);
border-bottom: 1rpx solid var(--kole-color-border);
background-color: var(--kole-color-page-bg);
font-size: var(--kole-m-font-size-label);
color: var(--kole-color-text-secondary);
}
.kole-m-cascader__path-item {
display: flex;
align-items: center;
box-sizing: border-box;
min-height: var(--kole-m-touch-target);
padding: 0 16rpx;
color: var(--kole-color-brand);
font-size: var(--kole-m-font-size-label);
}
/* 当前层级(不可点,是「你在这里」的锚点) */
.kole-m-cascader__path-item.is-current { color: var(--kole-color-text-body); }
.kole-m-cascader__sep {
color: var(--kole-color-text-placeholder);
font-size: var(--kole-m-font-size-caption);
}
.kole-m-cascader__panel {
max-height: var(--kole-m-cascader-panel-height);
}
.kole-m-cascader__option {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--kole-m-cascader-row-height);
padding: 16rpx var(--kole-m-gutter);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
}
.kole-m-cascader__option:active { background-color: var(--kole-color-table-header-bg); }
/* 状态 selected:当前路径上的项(与 aria-selected 同步) */
.kole-m-cascader__option.is-selected {
color: var(--kole-color-brand);
font-weight: 500;
}
/* 状态 disabled:置灰且不响应 */
.kole-m-cascader__option.is-disabled { color: var(--kole-color-text-disabled); }
.kole-m-cascader__label { flex: 1; }
/* 展开指示:有下级用 ›,叶子用 ✓(形状差异,不只靠颜色) */
.kole-m-cascader__arrow { color: var(--kole-color-text-placeholder); }
.kole-m-cascader__check { color: var(--kole-color-brand); }
/* 状态 empty:当前级没有可选项 */
.kole-m-cascader__empty {
padding: 80rpx var(--kole-m-gutter);
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
text-align: center;
}
</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-cascader.json(点击展开原始 JSON)
{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "44 · 级联选择器 Cascader",
"confidence": "high",
"slug": "mobile-cascader",
"name": "级联选择器 Cascader",
"semanticTypeCandidates": [
"cascader",
"tree-select",
"region-picker"
],
"variantDimensions": [
{
"name": "mode",
"values": [
"panel",
"popup"
]
},
{
"name": "showPath",
"values": [
"true",
"false"
]
},
{
"name": "round",
"values": [
"false",
"true"
]
}
],
"representativeVariants": [
{
"mode": "panel",
"showPath": "true",
"round": "false",
"label": "内嵌面板(页面内逐级选择)"
},
{
"mode": "popup",
"showPath": "true",
"round": "true",
"label": "底部浮层(带遮罩与确定取消)"
},
{
"mode": "panel",
"showPath": "false",
"round": "false",
"label": "不显示路径条(层级少时省一行高度)"
}
],
"anatomy": {
"cascader": "根元素,mode=panel 时内嵌在页面里,mode=popup 时是底部浮层",
"mask": "遮罩(仅 mode=popup),点击关闭",
"cascader__header": "取消 / 标题 / 确定三格(仅 mode=popup)",
"cascader__path": "路径条,按已选深度渲染;除末位外都可点,点了回退到该级",
"cascader__panel": "选项区,role=listbox + aria-label 说明当前在选第几级",
"cascader__option": "单个选项,role=option + aria-selected;有下级显示箭头,叶子显示勾号"
},
"structurePatterns": {
"mode": "panel(内嵌在页面里)/ popup(底部浮层,自带遮罩与确定取消)",
"showPath": "true(显示路径条,可回退)/ false(不显示,层级少的场景省一行高度)",
"round": "false(直角)/ true(浮层靠内容一侧切圆角)"
},
"usageHints": [
"在有层级关系的选项里逐级选到末级(省 → 市 → 区、品类 → 子品类 → SKU)",
"移动端一次只展示当前一层,已选路径收进上方路径条,靠路径条回退而不是靠「上一级」按钮",
"点某一级后下一级选项随之变化:面板始终只渲染当前一层,选择即下钻",
"点路径条里的上级可回退:回退到该级并重新展示它的下一级;末位是「你在这里」的锚点,不可点",
"级数不写死:路径条按已选深度渲染,两级与四级用同一份实现",
"选项行高与路径条各项热区均不小于 44px"
],
"doNotInvent": [
"层级数据源(由宿主传入 options 树,本组件不发明省市区库)",
"搜索定位某级选项(那需要把整棵树拍平,是独立的检索组件)",
"异步逐级加载的占位与重试流程",
"多选(一次选多条路径)"
],
"unknowns": [
"路径很长时路径条是折行还是横向滚动(当前折行,四级以上会占两行)",
"叶子被选中后是否要自动收起浮层(当前不自动收,等宿主决定)",
"是否要保留「上一级」按钮作为路径条之外的第二种回退入口"
],
"interaction": [
"点某一级后下一级选项随之变化:面板始终只渲染当前一层,选择即下钻",
"点路径条里的上级可回退:回退到该级并重新展示它的下一级;末位是「你在这里」的锚点,不可点",
"级数不写死:路径条按已选深度渲染,两级与四级用同一份实现",
"点到叶子结点后再点同级另一项,会截断更深的层级(改选不会留下旧的深层残留)",
"选项行高与路径条各项热区均不小于 44px;选项区滚动容器 -webkit-overflow-scrolling: touch"
],
"accessibility": [
"mode=panel 根元素 role=\"group\" + aria-label;mode=popup 根元素 role=\"dialog\" + aria-modal=\"true\"",
"选项区 role=\"listbox\",aria-label 随层级变化(如「级联选项 · 城市」),读屏能播报当前在第几级",
"选项 role=\"option\" + aria-selected;禁用项写 aria-disabled=\"true\"",
"路径条末位写 aria-current=\"true\",读屏播报「当前项」",
"路径条每一项是原生 button(键盘可达),末位用 disabled 让键盘跳过"
],
"api": {
"source": "implementation",
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
"props": [
{
"name": "mode",
"type": "'panel' | 'popup'",
"default": "'panel'",
"desc": "变体 mode:panel 内嵌在页面里,popup 是底部浮层(规格 §44.3)",
"required": false
},
{
"name": "round",
"type": "boolean",
"default": "false",
"desc": "变体 round:浮层靠内容一侧是否切圆角(规格 §44.3)",
"required": false
},
{
"name": "showPath",
"type": "boolean",
"default": "true",
"desc": "变体 showPath:是否显示路径条(规格 §44.3)",
"required": false
},
{
"name": "open",
"type": "boolean",
"default": "false",
"desc": "状态 open:mode=popup 时浮层是否展开(规格 §44.4)",
"required": false
},
{
"name": "closeOnMask",
"type": "boolean",
"default": "true",
"desc": "遮罩点击是否关闭(规格 §44.5)",
"required": false
},
{
"name": "value",
"type": "Array",
"default": "[]",
"desc": "当前选中路径,从根到当前级的 value 序列(规格 §44.6)",
"required": false
},
{
"name": "options",
"type": "Array",
"default": "[]",
"desc": "层级数据树(宿主提供,本组件不发明省市区库)(规格 §44.7)",
"required": false
},
{
"name": "disabled",
"type": "boolean",
"default": "false",
"desc": "状态 disabled:整块置灰且不响应(规格 §44.4)",
"required": false
},
{
"name": "title",
"type": "string",
"default": "''",
"desc": "浮层标题(空串时用默认「选择地区」)(规格 §44.2 cascader__header)",
"required": false
},
{
"name": "label",
"type": "string",
"default": "'级联选项'",
"desc": "选项区 aria-label 的前缀,读屏靠它知道这组在选什么(规格 §44.6)",
"required": false
}
],
"events": [
{
"name": "change",
"params": "value: Array",
"desc": "路径变化时触发(下钻或回退),回传从根到当前级的 value 序列(规格 §44.5)"
},
{
"name": "confirm",
"params": "value: Array",
"desc": "mode=popup 时点击确定触发(规格 §44.5)"
},
{
"name": "close",
"params": "—",
"desc": "mode=popup 时点击取消或遮罩(closeOnMask=true)触发(规格 §44.5)"
}
],
"slots": [
{
"name": "default",
"desc": "自定义内容(追加在面板之前,常见用法是放触发器)"
}
]
},
"variantClasses": {
"mode": {
"panel": [
".kole-m-cascader--panel"
],
"popup": [
".kole-m-cascader--popup"
]
},
"showPath": {
"true": [],
"false": [
".kole-m-cascader--no-path"
]
},
"round": {
"false": [],
"true": [
".kole-m-cascader--round"
]
}
},
"demos": [
{
"id": "levels",
"group": "01 组件类型",
"title": "逐级下钻",
"desc": "点省份后下一级换成城市:面板只渲染当前一层,避免 375px 下横向挤压。",
"variant": "mode=panel"
},
{
"id": "popup",
"group": "01 组件类型",
"title": "底部浮层",
"desc": "mode=popup:自带遮罩与确定取消,遮罩点击关闭。",
"variant": "mode=popup"
},
{
"id": "back",
"group": "02 组件状态",
"title": "回退上一级",
"desc": "点路径条里的上级即退回该级,重新展示它的下一级;末位是「你在这里」的锚点。",
"variant": "状态 selected(路径条回退)"
},
{
"id": "states",
"group": "02 组件状态",
"title": "禁用项与空态",
"desc": "禁用结点置灰并写 aria-disabled;当前级无子项时显示占位文案。",
"variant": "状态 disabled | empty"
}
],
"related": [
{
"slug": "mobile-picker",
"why": "选项之间没有层级关系(城市平铺)时用选择器,不要用级联去拼一棵假树"
},
{
"slug": "mobile-popup",
"why": "通用弹出层;mode=popup 本身就是底部浮层,不要在弹出层里再套一层"
},
{
"slug": "cell",
"why": "「已选地区」的展示入口用单元格,点开才拉起级联"
},
{
"slug": "mobile-list",
"why": "只需要展示一层可选项且不需要回退路径时,列表更轻"
},
{
"slug": "actionsheet",
"why": "动作面板面向「点一下即执行」,级联面向「逐级缩小范围再确认」"
}
]
}