Regression / regression (push) Canceled after 0s
安全:CHANGELOG.md 是仓库内部变更流水(含服务器目录、镜像回滚标签、内网网段、
部署时序、AI 工作流用语),此前被 build-site.ps1 / precompute.mjs / build-mobile.mjs
原样注入站点,而站点是公网可下载的静态文件 —— 抓一次 /site/m/changelog.html
即可拿到内网地址段与服务器目录布局。
- 新增 tools/lib/redact-publish.mjs(零依赖,构建期过滤,不改 CHANGELOG.md,
内部可追溯性完整保留)
- 接入 precompute.mjs(PC data.json/changelog.json)与 build-mobile.mjs
(移动端更新日志页);build-site.ps1 不碰(ASCII-only 铁律)
- 只处理 changelog 字段:components[].sources 是规范实现源码,逐字保真
(详情页代码区主动高亮注释,剥注释会破坏该功能)
- 实测消除:/opt/aurora-admin.prev-*、kole-ui-showcase:pre-*、docker compose、
192.168.5.7、16 位产物指纹、1531/1531、并发会话/本会话/派子 agent
- settings.html 演示占位 IP 192.168.5.0/24(= 真实网段)改为 RFC 5737 的 192.0.2.0/24
品牌:Kole Cup 饮料杯标记定稿(几何 K → 圆角杯盖 + 杯身负空间 K,无吸管),
brand-mark.json 升 schemaVersion 3(paths 支持 { d, evenodd }),
verify:brand 增至 25 条(新增 B9b:负空间必须带 fill-rule)。
验证:发布集 2465 文件全量扫描 0 泄露;PC 回归 100%(1464/1464) ·
移动端 100%(807/807);门禁品牌 25 / 隔离 31 / 移动文档 12 / 示例 9 / 版本 40 / i18n 17 全绿;
已按 AGENTS §九 发布公网,2461/2461 逐字节一致,五项验收全过。
已知未处理(既有缺口,ROADMAP S7-P28 已记录):data.mobile.json 的
meta.generated 为墙上时钟,会让 CI 的「生成物可复现」断言在重跑构建后永远非空;
该 CI 流水线本身亦从未通过(无 runner)。
2681 lines
128 KiB
HTML
2681 lines
128 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<script>
|
||
(function () {
|
||
var m = 'auto';
|
||
try { var v = localStorage.getItem('kole-mode'); if (v === 'light' || v === 'dark' || v === 'auto') m = v; } catch (e) {}
|
||
var dark = m === 'dark' || (m === 'auto' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
document.documentElement.classList.toggle('kole-dark', !!dark);
|
||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||
})();
|
||
</script>
|
||
<link rel="icon" href="../../assets/kole-mark.svg">
|
||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
||
<meta name="theme-color" content="#1C1F26" media="(prefers-color-scheme: dark)">
|
||
<title>级联选择器 Cascader — Kole UI Mobile</title>
|
||
<meta name="description" content="级联选择器 Cascader:移动端组件(6 端实现:CSS / H5 原生 / React / Vue 2 / Vue 3 / uni-app)。与 PC 端物理隔离:类名 kole-m-、令牌 --kole-m-。规格来源:移动端规格 44 · 级联选择器 Cascader。">
|
||
<link rel="stylesheet" href="../../../.design_library/kole-ui-mobile/colors_and_type.css">
|
||
<link rel="stylesheet" href="../style.css">
|
||
</head>
|
||
<body>
|
||
<header class="m-top">
|
||
<div class="m-top-inner">
|
||
<a class="m-logo" href="../index.html">
|
||
<span class="m-logo-mark" data-brand-mark="mono" aria-hidden="true"></span>
|
||
<span class="m-logo-text">Kole UI<em>移动端</em></span>
|
||
</a>
|
||
<div class="m-ver-picker" id="m-ver-picker">
|
||
<button id="m-ver-trigger" class="m-ver-trigger" type="button" data-single="1" aria-haspopup="listbox" aria-expanded="false" aria-label="选择版本" title="文档版本(清单来自部署根的 versions.json)">
|
||
<span id="m-ver">v1.0.0</span>
|
||
<svg class="m-ver-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
|
||
</button>
|
||
<div id="m-ver-menu" class="m-ver-menu hidden" role="listbox" aria-label="文档版本">
|
||
<div class="m-ver-menu-head" id="m-ver-head">文档版本</div>
|
||
</div>
|
||
</div>
|
||
<nav class="m-nav"> <a href="../index.html" class="active" aria-current="page">组件总览</a>
|
||
<a href="../guide.html">快速开始</a>
|
||
<a href="../design.html">设计规范</a>
|
||
<a href="../faq.html">常见问题</a>
|
||
<a href="../changelog.html">更新日志</a></nav>
|
||
<div class="m-mode">
|
||
<button id="m-mode-trigger" class="m-mode-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-label="主题模式" title="主题模式">
|
||
<svg class="m-mode-icon" id="m-icon-moon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||
<svg class="m-mode-icon" id="m-icon-sun" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||
<svg class="m-mode-icon" id="m-icon-auto" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||
<span id="m-mode-cur">自动模式</span>
|
||
<svg class="m-mode-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
|
||
</button>
|
||
<div id="m-mode-menu" class="m-menu hidden" role="listbox" aria-label="主题模式">
|
||
<div class="m-menu-head">主题模式</div>
|
||
<button class="m-menu-opt" type="button" role="option" data-mode="light" aria-selected="false"><span data-mode-label="light">日间模式</span></button>
|
||
<button class="m-menu-opt" type="button" role="option" data-mode="dark" aria-selected="false"><span data-mode-label="dark">夜间模式</span></button>
|
||
<button class="m-menu-opt" type="button" role="option" data-mode="auto" aria-selected="true"><span data-mode-label="auto">自动模式</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="m-body">
|
||
<details class="m-side" open>
|
||
<summary><span>移动端导航</span><span class="cur">级联选择器</span></summary>
|
||
<nav>
|
||
<div class="m-side-section"><span>平台</span></div>
|
||
<a class="m-side-link m-side-platform" href="../../index.html"><span class="sp-top"><span class="sp-name">PC 端组件</span></span><span class="sp-sub">103 个中后台组件 · 5 端</span></a>
|
||
<a class="m-side-link m-side-platform" href="../index.html"><span class="sp-top"><span class="sp-name">移动端组件</span><span class="sp-badge">当前</span></span><span class="sp-sub">47 个组件 · 6 端 · 含 uni-app</span></a>
|
||
<div class="m-side-section"><span>开发指南</span></div>
|
||
<a class="m-side-link" href="../index.html">组件总览</a>
|
||
<a class="m-side-link" href="../guide.html">快速开始</a>
|
||
<a class="m-side-link" href="../design.html">设计规范</a>
|
||
<a class="m-side-link" href="../faq.html">常见问题</a>
|
||
<a class="m-side-link" href="../changelog.html">更新日志</a>
|
||
<a class="m-side-link" href="../platform.html">平台与端<em>移动端</em></a>
|
||
<a class="m-side-link" href="../../../tests/mobile/index.html" target="_blank">测试与回归<em>移动端</em></a>
|
||
<div class="m-side-section"><span>组件</span><span class="cnt">47</span></div>
|
||
<div class="m-side-group"><span>通用</span><span class="cnt">5</span></div>
|
||
<a class="m-side-link" href="mobile-button.html">按钮<em>Button</em></a>
|
||
<a class="m-side-link" href="mobile-divider.html">分割线<em>Divider</em></a>
|
||
<a class="m-side-link" href="mobile-icon.html">图标<em>Icon</em></a>
|
||
<a class="m-side-link" href="mobile-layout.html">布局<em>Layout</em></a>
|
||
<a class="m-side-link" href="mobile-link.html">链接<em>Link</em></a>
|
||
<div class="m-side-group"><span>导航</span><span class="cnt">6</span></div>
|
||
<a class="m-side-link" href="navbar.html">顶部导航栏<em>NavBar</em></a>
|
||
<a class="m-side-link" href="tabbar.html">底部标签栏<em>TabBar</em></a>
|
||
<a class="m-side-link" href="mobile-grid.html">宫格<em>Grid</em></a>
|
||
<a class="m-side-link" href="mobile-steps.html">步骤条<em>Steps</em></a>
|
||
<a class="m-side-link" href="mobile-segmented.html">分段器<em>Segmented</em></a>
|
||
<a class="m-side-link" href="mobile-sticky.html">吸顶容器<em>Sticky</em></a>
|
||
<div class="m-side-group"><span>数据录入</span><span class="cnt">16</span></div>
|
||
<a class="m-side-link" href="mobile-numberkeyboard.html">数字键盘<em>NumberKeyboard</em></a>
|
||
<a class="m-side-link" href="mobile-datepicker.html">日期选择器<em>DatePicker</em></a>
|
||
<a class="m-side-link" href="mobile-input.html">输入框<em>Input</em></a>
|
||
<a class="m-side-link" href="mobile-search.html">搜索框<em>Search</em></a>
|
||
<a class="m-side-link" href="mobile-stepper.html">步进器<em>Stepper</em></a>
|
||
<a class="m-side-link" href="mobile-switch.html">开关<em>Switch</em></a>
|
||
<a class="m-side-link" href="mobile-textarea.html">多行文本框<em>Textarea</em></a>
|
||
<a class="m-side-link" href="mobile-checkbox.html">多选框<em>Checkbox</em></a>
|
||
<a class="m-side-link" href="mobile-form.html">表单<em>Form</em></a>
|
||
<a class="m-side-link" href="mobile-radio.html">单选框<em>Radio</em></a>
|
||
<a class="m-side-link" href="mobile-rate.html">评分<em>Rate</em></a>
|
||
<a class="m-side-link" href="mobile-slider.html">滑动选择器<em>Slider</em></a>
|
||
<a class="m-side-link is-active" href="mobile-cascader.html" aria-current="page">级联选择器<em>Cascader</em></a>
|
||
<a class="m-side-link" href="mobile-colorpicker.html">颜色选择器<em>ColorPicker</em></a>
|
||
<a class="m-side-link" href="mobile-picker.html">选择器<em>Picker</em></a>
|
||
<a class="m-side-link" href="mobile-upload.html">上传<em>Upload</em></a>
|
||
<div class="m-side-group"><span>数据展示</span><span class="cnt">8</span></div>
|
||
<a class="m-side-link" href="cell.html">单元格<em>Cell</em></a>
|
||
<a class="m-side-link" href="mobile-badge.html">徽标<em>Badge</em></a>
|
||
<a class="m-side-link" href="mobile-tag.html">标签<em>Tag</em></a>
|
||
<a class="m-side-link" href="mobile-avatar.html">头像<em>Avatar</em></a>
|
||
<a class="m-side-link" href="mobile-list.html">列表<em>List</em></a>
|
||
<a class="m-side-link" href="mobile-collapse.html">折叠面板<em>Collapse</em></a>
|
||
<a class="m-side-link" href="mobile-table.html">表格<em>Table</em></a>
|
||
<a class="m-side-link" href="mobile-typography.html">排版<em>Typography</em></a>
|
||
<div class="m-side-group"><span>反馈</span><span class="cnt">12</span></div>
|
||
<a class="m-side-link" href="actionsheet.html">动作面板<em>ActionSheet</em></a>
|
||
<a class="m-side-link" href="pullrefresh.html">下拉刷新<em>PullRefresh</em></a>
|
||
<a class="m-side-link" href="swipecell.html">滑动单元格<em>SwipeCell</em></a>
|
||
<a class="m-side-link" href="mobile-popup.html">弹出层<em>Popup</em></a>
|
||
<a class="m-side-link" href="mobile-toast.html">轻提示<em>Toast</em></a>
|
||
<a class="m-side-link" href="mobile-dialog.html">对话框<em>Dialog</em></a>
|
||
<a class="m-side-link" href="mobile-noticebar.html">通知栏<em>NoticeBar</em></a>
|
||
<a class="m-side-link" href="mobile-loading.html">加载<em>Loading</em></a>
|
||
<a class="m-side-link" href="mobile-progress.html">进度条<em>Progress</em></a>
|
||
<a class="m-side-link" href="mobile-message.html">消息通知<em>Message</em></a>
|
||
<a class="m-side-link" href="mobile-overlay.html">遮罩层<em>Overlay</em></a>
|
||
<a class="m-side-link" href="mobile-popover.html">弹出气泡<em>Popover</em></a>
|
||
</nav>
|
||
</details>
|
||
<main class="m-main">
|
||
<section class="m-hero">
|
||
<h1>级联选择器<span class="en" style="font-family: var(--kole-font-family-num); font-size: 14px; color: var(--kole-color-text-secondary); margin-left: 8px;">Cascader</span></h1>
|
||
<p>在有层级关系的选项里逐级选到末级(省 → 市 → 区、品类 → 子品类 → SKU)</p>
|
||
<p style="margin-top: 8px;" class="m-chips">
|
||
<span class="m-chip m">数据录入</span>
|
||
<span class="m-chip">规格 44 · 级联选择器 Cascader</span>
|
||
<span class="m-chip">6 端实现</span>
|
||
<span class="m-chip">触摸优先</span>
|
||
</p>
|
||
<div class="m-code" style="margin-top: var(--kole-space-12);">
|
||
<div class="m-code-head"><span>引入(H5 原生;其余 5 端见「快速开始」)</span><button class="m-copy" type="button" data-copy="code-import">复制</button></div>
|
||
<pre id="code-import"><code><!-- ① 令牌: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 端实现一致) --></code></pre>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="demo">演示</h2>
|
||
<p class="m-sub">每个演示都是真实渲染:预览帧加载 <code>frameworks-mobile/Cascader.html?demo=<id></code>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 <a href="../../../frameworks-mobile/Cascader.html" target="_blank">演示页 ↗</a>。</p>
|
||
<h3 class="m-demo-group">01 组件类型</h3>
|
||
<div class="m-demo" id="demo-levels">
|
||
<div class="m-demo-head"><span class="m-demo-title">逐级下钻</span><span class="m-chip">mode=panel</span></div>
|
||
<p class="m-demo-desc">点省份后下一级换成城市:面板只渲染当前一层,避免 375px 下横向挤压。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Cascader.html?demo=levels" title="级联选择器 Cascader · 逐级下钻" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 16 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Cascader.html · levels</span><button class="m-copy" type="button" data-copy="code-demo-1">复制</button></div>
|
||
<pre id="code-demo-1"><code><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></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-popup">
|
||
<div class="m-demo-head"><span class="m-demo-title">底部浮层</span><span class="m-chip">mode=popup</span></div>
|
||
<p class="m-demo-desc">mode=popup:自带遮罩与确定取消,遮罩点击关闭。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Cascader.html?demo=popup" title="级联选择器 Cascader · 底部浮层" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 32 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Cascader.html · popup</span><button class="m-copy" type="button" data-copy="code-demo-2">复制</button></div>
|
||
<pre id="code-demo-2"><code><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></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<h3 class="m-demo-group">02 组件状态</h3>
|
||
<div class="m-demo" id="demo-back">
|
||
<div class="m-demo-head"><span class="m-demo-title">回退上一级</span><span class="m-chip">状态 selected(路径条回退)</span></div>
|
||
<p class="m-demo-desc">点路径条里的上级即退回该级,重新展示它的下一级;末位是「你在这里」的锚点。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Cascader.html?demo=back" title="级联选择器 Cascader · 回退上一级" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 20 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Cascader.html · back</span><button class="m-copy" type="button" data-copy="code-demo-3">复制</button></div>
|
||
<pre id="code-demo-3"><code><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></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-states">
|
||
<div class="m-demo-head"><span class="m-demo-title">禁用项与空态</span><span class="m-chip">状态 disabled | empty</span></div>
|
||
<p class="m-demo-desc">禁用结点置灰并写 aria-disabled;当前级无子项时显示占位文案。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Cascader.html?demo=states" title="级联选择器 Cascader · 禁用项与空态" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 27 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Cascader.html · states</span><button class="m-copy" type="button" data-copy="code-demo-4">复制</button></div>
|
||
<pre id="code-demo-4"><code><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></code></pre></div>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="api">API</h2>
|
||
<p class="m-sub">props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs</p>
|
||
<h3 class="m-h3">Props</h3>
|
||
<table class="m-table">
|
||
<thead><tr><th>名称</th><th>类型</th><th>默认值</th><th>说明</th><th>必传</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>mode</code></td><td><code>'panel' | 'popup'</code></td><td><code>'panel'</code></td><td>变体 mode:panel 内嵌在页面里,popup 是底部浮层(规格 §44.3)</td><td>N</td></tr>
|
||
<tr><td><code>round</code></td><td><code>boolean</code></td><td><code>false</code></td><td>变体 round:浮层靠内容一侧是否切圆角(规格 §44.3)</td><td>N</td></tr>
|
||
<tr><td><code>showPath</code></td><td><code>boolean</code></td><td><code>true</code></td><td>变体 showPath:是否显示路径条(规格 §44.3)</td><td>N</td></tr>
|
||
<tr><td><code>open</code></td><td><code>boolean</code></td><td><code>false</code></td><td>状态 open:mode=popup 时浮层是否展开(规格 §44.4)</td><td>N</td></tr>
|
||
<tr><td><code>closeOnMask</code></td><td><code>boolean</code></td><td><code>true</code></td><td>遮罩点击是否关闭(规格 §44.5)</td><td>N</td></tr>
|
||
<tr><td><code>value</code></td><td><code>Array<string></code></td><td><code>[]</code></td><td>当前选中路径,从根到当前级的 value 序列(规格 §44.6)</td><td>N</td></tr>
|
||
<tr><td><code>options</code></td><td><code>Array<string | { label: string, value: string, disabled?: boolean, children?: Array }></code></td><td><code>[]</code></td><td>层级数据树(宿主提供,本组件不发明省市区库)(规格 §44.7)</td><td>N</td></tr>
|
||
<tr><td><code>disabled</code></td><td><code>boolean</code></td><td><code>false</code></td><td>状态 disabled:整块置灰且不响应(规格 §44.4)</td><td>N</td></tr>
|
||
<tr><td><code>title</code></td><td><code>string</code></td><td><code>''</code></td><td>浮层标题(空串时用默认「选择地区」)(规格 §44.2 cascader__header)</td><td>N</td></tr>
|
||
<tr><td><code>label</code></td><td><code>string</code></td><td><code>'级联选项'</code></td><td>选项区 aria-label 的前缀,读屏靠它知道这组在选什么(规格 §44.6)</td><td>N</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<p class="m-note">「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。</p>
|
||
<h3 class="m-h3">事件</h3>
|
||
<table class="m-table">
|
||
<thead><tr><th>名称</th><th>参数</th><th>说明</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>change</code></td><td><code>value: Array<string></code></td><td>路径变化时触发(下钻或回退),回传从根到当前级的 value 序列(规格 §44.5)</td></tr>
|
||
<tr><td><code>confirm</code></td><td><code>value: Array<string></code></td><td>mode=popup 时点击确定触发(规格 §44.5)</td></tr>
|
||
<tr><td><code>close</code></td><td><code>—</code></td><td>mode=popup 时点击取消或遮罩(closeOnMask=true)触发(规格 §44.5)</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<h3 class="m-h3">插槽</h3>
|
||
<table class="m-table">
|
||
<thead><tr><th>名称</th><th>说明</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>default</code></td><td>自定义内容(追加在面板之前,常见用法是放触发器)</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<h3 class="m-h3">CSS 变量</h3>
|
||
<p class="m-sub">组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。</p>
|
||
<table class="m-table">
|
||
<thead><tr><th>名称</th><th>默认值</th><th>说明</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>--kole-m-cascader-row-height</code></td><td><code>var(--kole-m-touch-target)</code></td><td>选项行高(≥44px 触控最小边长)</td></tr>
|
||
<tr><td><code>--kole-m-cascader-panel-height</code></td><td><code>240px</code></td><td>选项区最大高度</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="usage">何时使用</h2>
|
||
<ul>
|
||
<li>在有层级关系的选项里逐级选到末级(省 → 市 → 区、品类 → 子品类 → SKU)</li>
|
||
<li>移动端一次只展示当前一层,已选路径收进上方路径条,靠路径条回退而不是靠「上一级」按钮</li>
|
||
<li>点某一级后下一级选项随之变化:面板始终只渲染当前一层,选择即下钻</li>
|
||
<li>点路径条里的上级可回退:回退到该级并重新展示它的下一级;末位是「你在这里」的锚点,不可点</li>
|
||
<li>级数不写死:路径条按已选深度渲染,两级与四级用同一份实现</li>
|
||
<li>选项行高与路径条各项热区均不小于 44px</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="interaction">交互与触控</h2>
|
||
<ul>
|
||
<li>点某一级后下一级选项随之变化:面板始终只渲染当前一层,选择即下钻</li>
|
||
<li>点路径条里的上级可回退:回退到该级并重新展示它的下一级;末位是「你在这里」的锚点,不可点</li>
|
||
<li>级数不写死:路径条按已选深度渲染,两级与四级用同一份实现</li>
|
||
<li>点到叶子结点后再点同级另一项,会截断更深的层级(改选不会留下旧的深层残留)</li>
|
||
<li>选项行高与路径条各项热区均不小于 44px;选项区滚动容器 -webkit-overflow-scrolling: touch</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="a11y">无障碍</h2>
|
||
<ul>
|
||
<li>mode=panel 根元素 role="group" + aria-label;mode=popup 根元素 role="dialog" + aria-modal="true"</li>
|
||
<li>选项区 role="listbox",aria-label 随层级变化(如「级联选项 · 城市」),读屏能播报当前在第几级</li>
|
||
<li>选项 role="option" + aria-selected;禁用项写 aria-disabled="true"</li>
|
||
<li>路径条末位写 aria-current="true",读屏播报「当前项」</li>
|
||
<li>路径条每一项是原生 button(键盘可达),末位用 disabled 让键盘跳过</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="related">相似组件</h2>
|
||
<p class="m-sub">从「该用哪一个」的角度区分;PC 端的对应实现见 <a href="../../index.html">PC 文档站</a>。</p>
|
||
<table class="m-table">
|
||
<thead><tr><th>组件</th><th>何时用它而不是本组件</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><a href="mobile-picker.html">选择器</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">Picker</em></td><td>选项之间没有层级关系(城市平铺)时用选择器,不要用级联去拼一棵假树</td></tr>
|
||
<tr><td><a href="mobile-popup.html">弹出层</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">Popup</em></td><td>通用弹出层;mode=popup 本身就是底部浮层,不要在弹出层里再套一层</td></tr>
|
||
<tr><td><a href="cell.html">单元格</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">Cell</em></td><td>「已选地区」的展示入口用单元格,点开才拉起级联</td></tr>
|
||
<tr><td><a href="mobile-list.html">列表</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">List</em></td><td>只需要展示一层可选项且不需要回退路径时,列表更轻</td></tr>
|
||
<tr><td><a href="actionsheet.html">动作面板</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">ActionSheet</em></td><td>动作面板面向「点一下即执行」,级联面向「逐级缩小范围再确认」</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="gaps">规格未定 / 禁止发明</h2>
|
||
<table class="m-table">
|
||
<thead><tr><th>类别</th><th>条目</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><span class="m-chip">禁止发明</span></td><td>层级数据源(由宿主传入 options 树,本组件不发明省市区库)</td></tr>
|
||
<tr><td><span class="m-chip">禁止发明</span></td><td>搜索定位某级选项(那需要把整棵树拍平,是独立的检索组件)</td></tr>
|
||
<tr><td><span class="m-chip">禁止发明</span></td><td>异步逐级加载的占位与重试流程</td></tr>
|
||
<tr><td><span class="m-chip">禁止发明</span></td><td>多选(一次选多条路径)</td></tr>
|
||
<tr><td><span class="m-chip">规格未定</span></td><td>路径很长时路径条是折行还是横向滚动(当前折行,四级以上会占两行)</td></tr>
|
||
<tr><td><span class="m-chip">规格未定</span></td><td>叶子被选中后是否要自动收起浮层(当前不自动收,等宿主决定)</td></tr>
|
||
<tr><td><span class="m-chip">规格未定</span></td><td>是否要保留「上一级」按钮作为路径条之外的第二种回退入口</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="anatomy">结构(anatomy)</h2>
|
||
<table class="m-table">
|
||
<thead><tr><th>字段</th><th>说明</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>cascader</code></td><td>根元素,mode=panel 时内嵌在页面里,mode=popup 时是底部浮层</td></tr>
|
||
<tr><td><code>mask</code></td><td>遮罩(仅 mode=popup),点击关闭</td></tr>
|
||
<tr><td><code>cascader__header</code></td><td>取消 / 标题 / 确定三格(仅 mode=popup)</td></tr>
|
||
<tr><td><code>cascader__path</code></td><td>路径条,按已选深度渲染;除末位外都可点,点了回退到该级</td></tr>
|
||
<tr><td><code>cascader__panel</code></td><td>选项区,role=listbox + aria-label 说明当前在选第几级</td></tr>
|
||
<tr><td><code>cascader__option</code></td><td>单个选项,role=option + aria-selected;有下级显示箭头,叶子显示勾号</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="variants">变体维度与类名映射</h2>
|
||
<p class="m-sub">类名映射由构建脚本从契约 <code>variantClasses</code> 生成,并被 <code>verify:mobile-docs</code> 逐条对照组件 CSS 校验(类/变量必须真实存在)。</p>
|
||
<table class="m-table">
|
||
<thead><tr><th>维度</th><th>取值</th><th>对应类名 / 变量</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>mode</code></td><td>panel / popup</td><td><div><b>panel</b> <span class="m-cls">.kole-m-cascader--panel</span></div><div><b>popup</b> <span class="m-cls">.kole-m-cascader--popup</span></div></td></tr>
|
||
<tr><td><code>showPath</code></td><td>true / false</td><td><div><b>true</b> <span class="m-none">(由数据驱动,无专属类)</span></div><div><b>false</b> <span class="m-cls">.kole-m-cascader--no-path</span></div></td></tr>
|
||
<tr><td><code>round</code></td><td>false / true</td><td><div><b>false</b> <span class="m-none">(由数据驱动,无专属类)</span></div><div><b>true</b> <span class="m-cls">.kole-m-cascader--round</span></div></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="rep-variants">代表变体</h2>
|
||
<table class="m-table">
|
||
<thead><tr><th>变体</th><th>标签</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>mode=panel · showPath=true · round=false</code></td><td>内嵌面板(页面内逐级选择)</td></tr>
|
||
<tr><td><code>mode=popup · showPath=true · round=true</code></td><td>底部浮层(带遮罩与确定取消)</td></tr>
|
||
<tr><td><code>mode=panel · showPath=false · round=false</code></td><td>不显示路径条(层级少时省一行高度)</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="tokens">用到的令牌</h2>
|
||
<p class="m-sub">构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。</p>
|
||
<div class="m-chips">
|
||
<span class="m-chip m" title="面板/抽屉滑出">--kole-m-duration-slide</span>
|
||
<span class="m-chip m" title="">--kole-m-ease-slide</span>
|
||
<span class="m-chip m" title="正文 / 列表项">--kole-m-font-size-body</span>
|
||
<span class="m-chip m" title="标签栏文字">--kole-m-font-size-caption</span>
|
||
<span class="m-chip m" title="次级说明">--kole-m-font-size-label</span>
|
||
<span class="m-chip m" title="页面左右留白">--kole-m-gutter</span>
|
||
<span class="m-chip m" title="底部安全区;支持 env() 时由 @supports 覆盖为 env(safe-area-inset-bottom)">--kole-m-safe-bottom</span>
|
||
<span class="m-chip m" title="可点区域最小边长">--kole-m-touch-target</span>
|
||
<span class="m-chip inherit" title="分割线/边框">--kole-color-border</span>
|
||
<span class="m-chip inherit" title="品牌主色:主按钮、选中态、链接">--kole-color-brand</span>
|
||
<span class="m-chip inherit" title="品牌浅色背景:选中背景、标签背景、表格悬停">--kole-color-brand-bg</span>
|
||
<span class="m-chip inherit" title="卡片背景">--kole-color-card-bg</span>
|
||
<span class="m-chip inherit" title="输入框聚焦外发光">--kole-color-focus-ring</span>
|
||
<span class="m-chip inherit" title="弹窗遮罩">--kole-color-mask</span>
|
||
<span class="m-chip inherit" title="页面背景">--kole-color-page-bg</span>
|
||
<span class="m-chip inherit" title="表头背景">--kole-color-table-header-bg</span>
|
||
<span class="m-chip inherit" title="正文文字 · 14.0:1">--kole-color-text-body</span>
|
||
<span class="m-chip inherit" title="禁用态文字 · WCAG 1.4.3 对禁用控件豁免(规范定义于组件1/2.txt)">--kole-color-text-disabled</span>
|
||
<span class="m-chip inherit" title="占位文字 · 4.54:1(原 #BFBFBF 仅 1.84:1)">--kole-color-text-placeholder</span>
|
||
<span class="m-chip inherit" title="次要文字 · 5.10:1(原 #8C8C8C 仅 3.36:1)">--kole-color-text-secondary</span>
|
||
<span class="m-chip inherit" title="标题文字 · 15.4:1">--kole-color-text-title</span>
|
||
<span class="m-chip inherit" title="">--kole-font-family</span>
|
||
<span class="m-chip inherit" title="按钮、输入框、选择器">--kole-radius-base</span>
|
||
<span class="m-chip inherit" title="卡片、弹窗、抽屉">--kole-radius-large</span>
|
||
<span class="m-chip inherit" title="">--kole-space-4</span>
|
||
<span class="m-chip inherit" title="">--kole-space-40</span>
|
||
<span class="m-chip inherit" title="">--kole-space-8</span>
|
||
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-cascader-panel-height</span>
|
||
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-cascader-row-height</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="sources">6 端源码</h2>
|
||
<p class="m-sub">同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。</p>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Cascader.css · 纯样式(CSS) · 258 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Cascader.css</span><button class="m-copy" type="button" data-copy="code-217">复制</button></div>
|
||
<pre id="code-217"><code>/* 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;
|
||
}
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Cascader.html · H5 原生(无框架) · 319 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Cascader.html</span><button class="m-copy" type="button" data-copy="code-218">复制</button></div>
|
||
<pre id="code-218"><code><!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>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Cascader.jsx · React · 241 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Cascader.jsx</span><button class="m-copy" type="button" data-copy="code-219">复制</button></div>
|
||
<pre id="code-219"><code>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>
|
||
);
|
||
}
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Cascader.vue2.vue · Vue 2 · 234 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Cascader.vue2.vue</span><button class="m-copy" type="button" data-copy="code-220">复制</button></div>
|
||
<pre id="code-220"><code><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>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Cascader.vue3.vue · Vue 3 · 201 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Cascader.vue3.vue</span><button class="m-copy" type="button" data-copy="code-221">复制</button></div>
|
||
<pre id="code-221"><code><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>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Cascader.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 360 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Cascader.uniapp.vue</span><button class="m-copy" type="button" data-copy="code-222">复制</button></div>
|
||
<pre id="code-222"><code><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>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="tests">测试与回归</h2>
|
||
<p class="m-sub">断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 <code>tests/_runtime.js</code>,触控行为动词来自移动端 <code>tests/mobile/_behaviors.js</code>)。</p>
|
||
<p>
|
||
<span class="m-status pass">断言 19 条 · 全部通过</span>
|
||
<span class="m-status">报告 2026-09-22 23:01:05</span>
|
||
</p>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>复现命令</span><button class="m-copy" type="button" data-copy="code-test">复制</button></div>
|
||
<pre id="code-test"><code>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 与源码一致性</code></pre>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="contract">设计契约</h2>
|
||
<details class="m-details">
|
||
<summary>components/mobile-cascader.json(点击展开原始 JSON)</summary>
|
||
<div class="m-code"><pre><code>{
|
||
"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<string>",
|
||
"default": "[]",
|
||
"desc": "当前选中路径,从根到当前级的 value 序列(规格 §44.6)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "options",
|
||
"type": "Array<string | { label: string, value: string, disabled?: boolean, children?: 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<string>",
|
||
"desc": "路径变化时触发(下钻或回退),回传从根到当前级的 value 序列(规格 §44.5)"
|
||
},
|
||
{
|
||
"name": "confirm",
|
||
"params": "value: Array<string>",
|
||
"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": "动作面板面向「点一下即执行」,级联面向「逐级缩小范围再确认」"
|
||
}
|
||
]
|
||
}</code></pre></div>
|
||
</details>
|
||
</section>
|
||
|
||
<footer class="m-foot">
|
||
<p>生成物:本页由 <code>node tools/build-mobile.mjs</code> 从契约 <code>components/mobile-cascader.json</code> 与 6 端源码生成,请勿手改。</p>
|
||
<p>规格原文:<code>.design_library/kole-ui-mobile/spec/移动端规格.md</code> · 隔离门禁:<code>node tools/verify-mobile-isolation.mjs</code></p>
|
||
</footer>
|
||
</main>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
/* 左栏:≤1000px 默认收起(手机上不让导航占满一屏),跨断点时同步一次;不监听 resize,
|
||
免得跟用户手动的展开/收起抢状态。桌面不设 open 之外的动作。 */
|
||
var side = document.querySelector('details.m-side');
|
||
if (side && window.matchMedia) {
|
||
var mq = window.matchMedia('(max-width: 1000px)');
|
||
var sync = function () { if (mq.matches) side.removeAttribute('open'); else side.setAttribute('open', ''); };
|
||
sync();
|
||
if (mq.addEventListener) mq.addEventListener('change', sync);
|
||
else if (mq.addListener) mq.addListener(sync);
|
||
}
|
||
/* 单演示预览帧按内容高度自适应(同源可读 contentDocument;上限 360px、下限 140px)。
|
||
固定高度会在内容少的演示上留一大片空白(实测 SwipeCell 单个演示只用 ~120px)。 */
|
||
function fitDemoFrames() {
|
||
var frames = document.querySelectorAll('.m-demo-stage iframe');
|
||
Array.prototype.forEach.call(frames, function (f) {
|
||
var fit = function () {
|
||
try {
|
||
var d = f.contentDocument;
|
||
if (!d || !d.body) return;
|
||
/* 只量 body.scrollHeight:documentElement.scrollHeight 等于**帧视口高度**(html 撑满视口),
|
||
拿它做 max 会形成「帧高→视口高→量到的高度」反馈环,永远收敛在初始值(实测锁在 308px)。 */
|
||
var h = d.body.scrollHeight;
|
||
if (!h) h = d.documentElement ? d.documentElement.scrollHeight : 0;
|
||
if (!h) return;
|
||
f.style.height = Math.min(360, Math.max(140, h + 8)) + 'px';
|
||
} catch (e) { /* 跨源或未就绪:保持 CSS 兜底高度 */ }
|
||
};
|
||
f.addEventListener('load', function () { fit(); setTimeout(fit, 300); });
|
||
fit();
|
||
});
|
||
}
|
||
fitDemoFrames();
|
||
window.addEventListener('load', fitDemoFrames);
|
||
/* 主题模式(三态):与 PC 完全同一约定 —— localStorage[kole-mode] ∈ light|dark|auto,
|
||
auto 跟随系统,反色靠 html.kole-dark(令牌文件里已写好该组,两端共用一份令牌)。
|
||
用户在 PC 站选夜间,进移动端站也是夜间。 */
|
||
var MODE_KEY = 'kole-mode';
|
||
var MODE_LABEL = { light: '日间模式', dark: '夜间模式', auto: '自动模式' };
|
||
function readMode() {
|
||
try { var v = localStorage.getItem(MODE_KEY); if (v === 'light' || v === 'dark' || v === 'auto') return v; } catch (e) {}
|
||
return 'auto';
|
||
}
|
||
function systemDark() {
|
||
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
}
|
||
function applyMode(mode) {
|
||
var dark = mode === 'dark' || (mode === 'auto' && systemDark());
|
||
document.documentElement.classList.toggle('kole-dark', dark);
|
||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||
var cur = document.getElementById('m-mode-cur');
|
||
if (cur) cur.textContent = MODE_LABEL[mode] || MODE_LABEL.auto;
|
||
Array.prototype.forEach.call(document.querySelectorAll('.m-menu-opt'), function (o) {
|
||
o.setAttribute('aria-selected', o.getAttribute('data-mode') === mode ? 'true' : 'false');
|
||
});
|
||
/* 三态图标与 PC 同口径:显式选择时按时态给图标(日间=月亮 / 夜间=太阳),
|
||
自动模式下恒为「跟随系统」的显示器图标(此时两个显式图标都不显示)。 */
|
||
var moon = document.getElementById('m-icon-moon');
|
||
var sun = document.getElementById('m-icon-sun');
|
||
var auto = document.getElementById('m-icon-auto');
|
||
if (moon) moon.style.display = (mode === 'light') ? '' : 'none';
|
||
if (sun) sun.style.display = (mode === 'dark') ? '' : 'none';
|
||
if (auto) auto.style.display = (mode === 'auto') ? '' : 'none';
|
||
}
|
||
function bindModeMenu() {
|
||
var trigger = document.getElementById('m-mode-trigger');
|
||
var menu = document.getElementById('m-mode-menu');
|
||
if (!trigger || !menu) return;
|
||
var close = function (refocus) {
|
||
menu.classList.add('hidden'); trigger.setAttribute('aria-expanded', 'false');
|
||
if (refocus) trigger.focus();
|
||
};
|
||
trigger.addEventListener('click', function () {
|
||
var open = menu.classList.toggle('hidden');
|
||
trigger.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||
});
|
||
trigger.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') close(true);
|
||
});
|
||
Array.prototype.forEach.call(menu.querySelectorAll('.m-menu-opt'), function (opt) {
|
||
opt.addEventListener('click', function () {
|
||
var mode = opt.getAttribute('data-mode');
|
||
try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}
|
||
applyMode(mode);
|
||
close(true);
|
||
});
|
||
});
|
||
document.addEventListener('click', function (e) {
|
||
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && !trigger.contains(e.target)) close(false);
|
||
});
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && !menu.classList.contains('hidden')) close(true);
|
||
});
|
||
/* 自动模式下跟随系统切换 */
|
||
if (window.matchMedia) {
|
||
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||
var onChange = function () { if (readMode() === 'auto') applyMode('auto'); };
|
||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||
else if (mq.addListener) mq.addListener(onChange);
|
||
}
|
||
applyMode(readMode());
|
||
}
|
||
bindModeMenu();
|
||
/* 版本下拉:与 PC 顶栏同一实现口径 —— 清单从部署根取(<prefix>/versions.json 与
|
||
<prefix>/site/versions.json 两份按序尝试,取有可用条目的那份),拿不到就退回不可点的
|
||
纯角标(data-single="1"),不留一个点不开的下拉。
|
||
清单不许把用户带去外站:path 只允许 ".."(站点根自身)或 x.y.z 快照目录名。 */
|
||
var VERSION_KEY_PATH = '..';
|
||
function isValidVersionPath(p) {
|
||
if (typeof p !== 'string' || !p.length || p.indexOf('\\') >= 0 || p.indexOf('//') >= 0) return false;
|
||
if (p === VERSION_KEY_PATH) return true;
|
||
return /^\d+\.\d+\.\d+$/.test(p);
|
||
}
|
||
/* 清单里的 path 相对部署根,".." = /site/、x.y.z = /x.y.z/site/。
|
||
当前页可能不在部署根下(快照页),故先还原部署根绝对路径再算回相对当前页。 */
|
||
function versionHref(absSite) {
|
||
var i = location.pathname.indexOf('/site/');
|
||
var prefix = i >= 0 ? location.pathname.slice(0, i) : '';
|
||
if (!prefix || absSite.indexOf(prefix + '/') !== 0) return absSite;
|
||
var rest = absSite.slice(prefix.length + 1);
|
||
var up = '';
|
||
var segs = prefix.slice(1).split('/');
|
||
for (var k = 0; k < segs.length; k++) up += "../";
|
||
return up + rest;
|
||
}
|
||
function buildVersionList(doc) {
|
||
var raw = doc && doc.versions;
|
||
if (!raw || !raw.length) return [];
|
||
var i = location.pathname.indexOf('/site/');
|
||
var prefix = i >= 0 ? location.pathname.slice(0, i) : '';
|
||
var list = [];
|
||
for (var k = 0; k < raw.length; k++) {
|
||
var v = raw[k] || {};
|
||
var ver = v.version ? String(v.version) : '';
|
||
var path = v.path ? String(v.path) : '';
|
||
if (!ver || !isValidVersionPath(path)) continue;
|
||
var absSite = (path === VERSION_KEY_PATH ? '' : '/' + path) + '/site/';
|
||
list.push({ version: ver, date: v.date ? String(v.date) : '', latest: !!v.latest, href: versionHref(absSite), absSite: absSite, current: false });
|
||
}
|
||
if (list.length) {
|
||
/* 当前版本 = 绝对路径上命中的最长前缀(最具体的那个)——与 PC 的判定一致,
|
||
快照页与根站点都能算对。 */
|
||
var here = location.pathname;
|
||
var best = -1;
|
||
for (var j = 0; j < list.length; j++) {
|
||
if (here.indexOf(list[j].absSite) === 0 && list[j].absSite.length > best) best = list[j].absSite.length;
|
||
}
|
||
for (var m = 0; m < list.length; m++) list[m].current = best >= 0 && list[m].absSite.length === best;
|
||
if (best < 0 && list[0]) list[0].current = true;
|
||
}
|
||
return list;
|
||
}
|
||
function bindVersionMenu() {
|
||
var trigger = document.getElementById('m-ver-trigger');
|
||
var menu = document.getElementById('m-ver-menu');
|
||
var chip = document.getElementById('m-ver');
|
||
if (!trigger || !menu) return;
|
||
var close = function (refocus) {
|
||
menu.classList.add('hidden'); trigger.setAttribute('aria-expanded', 'false');
|
||
if (refocus) trigger.focus();
|
||
};
|
||
var interactive = false;
|
||
var render = function (list) {
|
||
interactive = !!(list && list.length);
|
||
/* 只有清单拿不到才退回纯角标;只要有一版就是下拉(里面至少能看清当前是哪个版本) */
|
||
trigger.setAttribute('data-single', interactive ? '0' : '1');
|
||
if (!interactive) { menu.classList.add("hidden"); return; }
|
||
var cur = null;
|
||
for (var k = 0; k < list.length; k++) if (list[k].current) cur = list[k];
|
||
if (chip) chip.textContent = 'v' + ((cur && cur.version) || VERSION);
|
||
/* 重建选项(幂等:重复调用不会叠加) */
|
||
Array.prototype.slice.call(menu.querySelectorAll('.m-ver-opt')).forEach(function (n) { n.remove(); });
|
||
list.forEach(function (it) {
|
||
var opt = document.createElement('button');
|
||
opt.type = 'button'; opt.className = 'm-ver-opt'; opt.setAttribute('role', 'option');
|
||
opt.setAttribute('data-version', it.version);
|
||
opt.setAttribute('aria-selected', it.current ? 'true' : 'false');
|
||
var tag = it.current ? '当前浏览' : (it.latest ? '最新版' : (it.date || ''));
|
||
opt.innerHTML = '<span class="m-ver-opt-num">v' + it.version + '</span><span class="m-ver-opt-tag">' + tag + '</span>';
|
||
if (it.href) opt.setAttribute('data-href', it.href);
|
||
opt.addEventListener("click", function () {
|
||
if (!it.current && it.href) location.href = it.href;
|
||
else close(true);
|
||
});
|
||
menu.appendChild(opt);
|
||
});
|
||
};
|
||
trigger.addEventListener('click', function () {
|
||
if (!interactive) return;
|
||
if (menu.classList.contains('hidden')) { menu.classList.remove('hidden'); trigger.setAttribute('aria-expanded', 'true'); }
|
||
else close(false);
|
||
});
|
||
trigger.addEventListener('keydown', function (e) {
|
||
if (!interactive) return;
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); menu.classList.remove('hidden'); trigger.setAttribute('aria-expanded', 'true'); }
|
||
else if (e.key === 'Escape') close(true);
|
||
});
|
||
document.addEventListener('click', function (e) {
|
||
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && !trigger.contains(e.target)) close(false);
|
||
});
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && !menu.classList.contains('hidden')) close(true);
|
||
});
|
||
/* 两份清单按序尝试(不并行:快照页那份必然 404,并行会把 404 记进控制台,
|
||
而站点门禁的「零控制台错误」是硬断言)。 */
|
||
if (!window.fetch || location.protocol === 'file:') return;
|
||
var i = location.pathname.indexOf('/site/');
|
||
if (i < 0) return;
|
||
var prefix = location.pathname.slice(0, i);
|
||
var urls = [prefix + '/versions.json', prefix + '/site/versions.json'];
|
||
var attempt = function (idx) {
|
||
if (idx >= urls.length) return;
|
||
fetch(urls[idx], { cache: 'no-store' })
|
||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||
.then(function (doc) { var list = buildVersionList(doc); if (list.length) render(list); else attempt(idx + 1); })
|
||
.catch(function () { attempt(idx + 1); });
|
||
};
|
||
attempt(0);
|
||
}
|
||
bindVersionMenu();
|
||
function done(btn) {
|
||
var old = btn.textContent; btn.textContent = '已复制'; btn.classList.add('is-done');
|
||
setTimeout(function () { btn.textContent = old; btn.classList.remove('is-done'); }, 1200);
|
||
}
|
||
document.addEventListener('click', function (e) {
|
||
var btn = e.target && e.target.closest ? e.target.closest('.m-copy') : null;
|
||
if (!btn) return;
|
||
var src = document.getElementById(btn.getAttribute('data-copy') || '');
|
||
var text = src ? (src.innerText || src.textContent || '') : '';
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(function () { done(btn); }, function () { fallback(btn, text); });
|
||
} else { fallback(btn, text); }
|
||
});
|
||
function fallback(btn, text) {
|
||
try {
|
||
var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', '');
|
||
ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta);
|
||
ta.select(); document.execCommand("copy"); document.body.removeChild(ta); done(btn);
|
||
} catch (err) { btn.textContent = '复制失败'; }
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|