移动端导航级联选择器

级联选择器Cascader

在有层级关系的选项里逐级选到末级(省 → 市 → 区、品类 → 子品类 → SKU)

数据录入 规格 44 · 级联选择器 Cascader 6 端实现 触摸优先

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

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

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

演示

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

01 组件类型

逐级下钻mode=panel

点省份后下一级换成城市:面板只渲染当前一层,避免 375px 下横向挤压。

查看代码(演示页原文 · 16 行)
frameworks-mobile/Cascader.html · levels
<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

mode=popup:自带遮罩与确定取消,遮罩点击关闭。

查看代码(演示页原文 · 32 行)
frameworks-mobile/Cascader.html · popup
<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 组件状态

回退上一级状态 selected(路径条回退)

点路径条里的上级即退回该级,重新展示它的下一级;末位是「你在这里」的锚点。

查看代码(演示页原文 · 20 行)
frameworks-mobile/Cascader.html · back
<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>
禁用项与空态状态 disabled | empty

禁用结点置灰并写 aria-disabled;当前级无子项时显示占位文案。

查看代码(演示页原文 · 27 行)
frameworks-mobile/Cascader.html · states
<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
roundbooleanfalse变体 round:浮层靠内容一侧是否切圆角(规格 §44.3)N
showPathbooleantrue变体 showPath:是否显示路径条(规格 §44.3)N
openbooleanfalse状态 open:mode=popup 时浮层是否展开(规格 §44.4)N
closeOnMaskbooleantrue遮罩点击是否关闭(规格 §44.5)N
valueArray<string>[]当前选中路径,从根到当前级的 value 序列(规格 §44.6)N
optionsArray<string | { label: string, value: string, disabled?: boolean, children?: Array }>[]层级数据树(宿主提供,本组件不发明省市区库)(规格 §44.7)N
disabledbooleanfalse状态 disabled:整块置灰且不响应(规格 §44.4)N
titlestring''浮层标题(空串时用默认「选择地区」)(规格 §44.2 cascader__header)N
labelstring'级联选项'选项区 aria-label 的前缀,读屏靠它知道这组在选什么(规格 §44.6)N

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

事件

名称参数说明
changevalue: Array<string>路径变化时触发(下钻或回退),回传从根到当前级的 value 序列(规格 §44.5)
confirmvalue: Array<string>mode=popup 时点击确定触发(规格 §44.5)
close—mode=popup 时点击取消或遮罩(closeOnMask=true)触发(规格 §44.5)

插槽

名称说明
default自定义内容(追加在面板之前,常见用法是放触发器)

CSS 变量

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

名称默认值说明
--kole-m-cascader-row-heightvar(--kole-m-touch-target)选项行高(≥44px 触控最小边长)
--kole-m-cascader-panel-height240px选项区最大高度

何时使用

  • 在有层级关系的选项里逐级选到末级(省 → 市 → 区、品类 → 子品类 → 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 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
modepanel / popup
panel .kole-m-cascader--panel
popup .kole-m-cascader--popup
showPathtrue / false
true (由数据驱动,无专属类)
false .kole-m-cascader--no-path
roundfalse / 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 令牌(改一处两端生效)。

--kole-m-duration-slide --kole-m-ease-slide --kole-m-font-size-body --kole-m-font-size-caption --kole-m-font-size-label --kole-m-gutter --kole-m-safe-bottom --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-bg --kole-color-card-bg --kole-color-focus-ring --kole-color-mask --kole-color-page-bg --kole-color-table-header-bg --kole-color-text-body --kole-color-text-disabled --kole-color-text-placeholder --kole-color-text-secondary --kole-color-text-title --kole-font-family --kole-radius-base --kole-radius-large --kole-space-4 --kole-space-40 --kole-space-8 --kole-m-cascader-panel-height --kole-m-cascader-row-height

6 端源码

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

frameworks-mobile/Cascader.css · 纯样式(CSS) · 258 行
frameworks-mobile/Cascader.css
/* 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 行
frameworks-mobile/Cascader.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · 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 行
frameworks-mobile/Cascader.jsx
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 行
frameworks-mobile/Cascader.vue2.vue
<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 行
frameworks-mobile/Cascader.vue3.vue
<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 行
frameworks-mobile/Cascader.uniapp.vue
<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": "动作面板面向「点一下即执行」,级联面向「逐级缩小范围再确认」"
    }
  ]
}