Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2169 lines
106 KiB
HTML
2169 lines
106 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="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Crect%20width='24'%20height='24'%20rx='6'%20fill='%232F54EB'/%3E%3Cg%20fill='none'%20stroke='%23FFFFFF'%20stroke-width='2.25'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M7%205.5V18.5'/%3E%3Cpath%20d='M17%205.5l-6.25%204.06'/%3E%3Cpath%20d='M10.75%2014.44L17%2018.5'/%3E%3C/g%3E%3C/svg%3E">
|
||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
||
<meta name="theme-color" content="#1C1F26" media="(prefers-color-scheme: dark)">
|
||
<title>滑动选择器 Slider — Kole UI Mobile</title>
|
||
<meta name="description" content="滑动选择器 Slider:移动端组件(6 端实现:CSS / H5 原生 / React / Vue 2 / Vue 3 / uni-app)。与 PC 端物理隔离:类名 kole-m-、令牌 --kole-m-。规格来源:移动端规格 35 · 滑动选择器 Slider。">
|
||
<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" aria-hidden="true"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 5.5V18.5"/><path d="M17 5.5l-6.25 4.06"/><path d="M10.75 14.44L17 18.5"/></svg></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 is-active" href="mobile-slider.html" aria-current="page">滑动选择器<em>Slider</em></a>
|
||
<a class="m-side-link" href="mobile-cascader.html">级联选择器<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;">Slider</span></h1>
|
||
<p>在一个连续区间里用一次滑动选出数值(预算上限、价格区间、音量、亮度)</p>
|
||
<p style="margin-top: 8px;" class="m-chips">
|
||
<span class="m-chip m">数据录入</span>
|
||
<span class="m-chip">规格 35 · 滑动选择器 Slider</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-slider.css">
|
||
|
||
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) --></code></pre>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="demo">演示</h2>
|
||
<p class="m-sub">每个演示都是真实渲染:预览帧加载 <code>frameworks-mobile/Slider.html?demo=<id></code>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 <a href="../../../frameworks-mobile/Slider.html" target="_blank">演示页 ↗</a>。</p>
|
||
<h3 class="m-demo-group">01 组件类型</h3>
|
||
<div class="m-demo" id="demo-basic">
|
||
<div class="m-demo-head"><span class="m-demo-title">基础用法</span><span class="m-chip">mode=single</span></div>
|
||
<p class="m-demo-desc">在轨道上按住拖动即改值;轨道可视 4px、热区 44px 高。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Slider.html?demo=basic" title="滑动选择器 Slider · 基础用法" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 17 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Slider.html · basic</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="basic">
|
||
<p class="demo-label">基础用法(在轨道上按住拖动即改值;轨道可视 4px、热区 44px 高)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider" id="sl-basic" data-min="0" data-max="100" data-step="1" data-value="40"
|
||
data-assert="slider-basic" data-behavior="swipe-sets-class:#sl-basic|is-dragging">
|
||
<span class="kole-m-slider__rail" role="slider" tabindex="0"
|
||
aria-label="预算上限" aria-valuemin="0" aria-valuemax="100" aria-valuenow="40">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="40"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">40</span>
|
||
</div>
|
||
</div>
|
||
<p class="demo-note">在轨道上按住即跳到手指处,横向拖动连续改值;松手后滑块 150ms 回弹到常态尺寸。</p>
|
||
</section></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-steps">
|
||
<div class="m-demo-head"><span class="m-demo-title">带刻度</span><span class="m-chip">marks=with-marks</span></div>
|
||
<p class="m-demo-desc">marks=with-marks:轨道上标出等分点;取值仍按 step 吸附。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Slider.html?demo=steps" title="滑动选择器 Slider · 带刻度" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 20 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Slider.html · steps</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="steps">
|
||
<p class="demo-label">带刻度(marks=with-marks:轨道上标出等分点)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider kole-m-slider--marks" id="sl-marks" data-min="0" data-max="100" data-step="1" data-value="60"
|
||
data-assert="slider-marks">
|
||
<span class="kole-m-slider__rail" role="slider" tabindex="0"
|
||
aria-label="亮度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__mark" style="left: 25%"></span>
|
||
<span class="kole-m-slider__mark" style="left: 50%"></span>
|
||
<span class="kole-m-slider__mark" style="left: 75%"></span>
|
||
<span class="kole-m-slider__thumb" data-value="60"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">60</span>
|
||
</div>
|
||
</div>
|
||
<p class="demo-note">刻度只是位置提示;取值仍按 data-step 吸附(默认 1),不会停在刻度之间。</p>
|
||
</section></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-range">
|
||
<div class="m-demo-head"><span class="m-demo-title">双滑块区间</span><span class="m-chip">mode=range</span></div>
|
||
<p class="m-demo-desc">mode=range:两个滑块各自独立拖动,左值不会越过右值。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Slider.html?demo=range" title="滑动选择器 Slider · 双滑块区间" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 17 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Slider.html · range</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="range">
|
||
<p class="demo-label">双滑块(mode=range:两个滑块各自独立拖动,左值不会越过右值)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider kole-m-slider--range" id="sl-range" data-min="0" data-max="100" data-step="1"
|
||
data-value="20" data-value-max="70" data-assert="slider-range">
|
||
<span class="kole-m-slider__rail" role="group" aria-label="价格区间" tabindex="0">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="20"></span>
|
||
<span class="kole-m-slider__thumb" data-value="70"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">20~70</span>
|
||
</div>
|
||
</div>
|
||
<p class="demo-note">两个滑块各有自己的 aria-valuenow;拖动时按「离手指更近」判定改哪一个。</p>
|
||
</section></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<h3 class="m-demo-group">02 组件状态</h3>
|
||
<div class="m-demo" id="demo-dragging">
|
||
<div class="m-demo-head"><span class="m-demo-title">拖动中</span><span class="m-chip">状态 dragging</span></div>
|
||
<p class="m-demo-desc">滑块由 20px 放大到 24px,并给轨道加一层浅底作「抓住了」的反馈。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Slider.html?demo=dragging" title="滑动选择器 Slider · 拖动中" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 16 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Slider.html · dragging</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="dragging">
|
||
<p class="demo-label">拖动中(滑块由 20px 放大到 24px,同时给轨道加一层浅底作「抓住了」的反馈)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider is-dragging" data-min="0" data-max="100" data-step="1" data-value="75"
|
||
data-assert="slider-dragging">
|
||
<span class="kole-m-slider__rail" role="slider" tabindex="0"
|
||
aria-label="音量" aria-valuemin="0" aria-valuemax="100" aria-valuenow="75">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="75"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">75</span>
|
||
</div>
|
||
</div>
|
||
</section></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-disabled">
|
||
<div class="m-demo-head"><span class="m-demo-title">禁用</span><span class="m-chip">disabled=true</span></div>
|
||
<p class="m-demo-desc">整条置灰且不响应:读屏播报不可用,指针事件不再改值。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Slider.html?demo=disabled" title="滑动选择器 Slider · 禁用" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 16 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Slider.html · disabled</span><button class="m-copy" type="button" data-copy="code-demo-5">复制</button></div>
|
||
<pre id="code-demo-5"><code><section class="demo-block" data-demo="disabled">
|
||
<p class="demo-label">禁用(整条置灰且不响应:读屏会播报不可用,指针事件不再改值)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider is-disabled" data-min="0" data-max="100" data-value="30"
|
||
aria-disabled="true" data-assert="slider-disabled">
|
||
<span class="kole-m-slider__rail" role="slider" aria-label="热度权重"
|
||
aria-valuemin="0" aria-valuemax="100" aria-valuenow="30" aria-disabled="true">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="30"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">30</span>
|
||
</div>
|
||
</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>value</code></td><td><code>number</code></td><td><code>0</code></td><td>受控当前值(range 模式下是左值),与 aria-valuenow 同步写(规格 §35.5)</td><td>N</td></tr>
|
||
<tr><td><code>valueMax</code></td><td><code>number</code></td><td><code>100</code></td><td>mode=range 时的右值(single 模式忽略)(规格 §35.3)</td><td>N</td></tr>
|
||
<tr><td><code>min</code></td><td><code>number</code></td><td><code>0</code></td><td>区间下限,拖动与键盘步进都不会低于它(规格 §35.5)</td><td>N</td></tr>
|
||
<tr><td><code>max</code></td><td><code>number</code></td><td><code>100</code></td><td>区间上限,拖动与键盘步进都不会高于它(规格 §35.5)</td><td>N</td></tr>
|
||
<tr><td><code>step</code></td><td><code>number</code></td><td><code>1</code></td><td>取值吸附步长;也是双滑块的最小间距与键盘步进量(规格 §35.5)</td><td>N</td></tr>
|
||
<tr><td><code>mode</code></td><td><code>'single' | 'range'</code></td><td><code>'single'</code></td><td>变体 mode:单滑块还是双滑块区间(规格 §35.3)</td><td>N</td></tr>
|
||
<tr><td><code>marks</code></td><td><code>boolean</code></td><td><code>false</code></td><td>变体 marks:轨道上是否标出等分刻度点(规格 §35.3)</td><td>N</td></tr>
|
||
<tr><td><code>showValue</code></td><td><code>boolean</code></td><td><code>true</code></td><td>变体 showValue:是否在轨道右侧显示当前值(规格 §35.3)</td><td>N</td></tr>
|
||
<tr><td><code>disabled</code></td><td><code>boolean</code></td><td><code>false</code></td><td>状态 disabled:整条置灰且不响应,指针与键盘都不改值(规格 §35.4)</td><td>N</td></tr>
|
||
<tr><td><code>label</code></td><td><code>string</code></td><td><code>'滑动选择'</code></td><td>无障碍名称,落到 role="slider" 的 aria-label(规格 §35.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)</code></td><td>拖动或键盘步进改值时触发,连续回传当前值(range 模式回传 [左值, 右值])(规格 §35.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>数值之后的自定义内容(如单位文案,规格 §35.2 slider)</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-slider-thumb</code></td><td><code>20px</code></td><td>滑块视觉直径</td></tr>
|
||
<tr><td><code>--kole-m-slider-thumb-active</code></td><td><code>24px</code></td><td>拖动中的滑块直径</td></tr>
|
||
<tr><td><code>--kole-m-slider-track</code></td><td><code>4px</code></td><td>轨道可视高度</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="usage">何时使用</h2>
|
||
<ul>
|
||
<li>在一个连续区间里用一次滑动选出数值(预算上限、价格区间、音量、亮度)</li>
|
||
<li>轨道可视高度只有 4px,但整条轨道所在的行必须是 44px 高的热区</li>
|
||
<li>按下任意位置就要跳到该处 —— 触屏上要求用户精确抓住 20px 的滑块是点不中的</li>
|
||
<li>取值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值、右值不越过左值</li>
|
||
<li>值变化立即触发 change 事件(连续回传),不做防抖;松手时才做业务提交</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="interaction">交互与触控</h2>
|
||
<ul>
|
||
<li>轨道热区高 44px,**按下任意位置即跳到该处**,随后横向拖动连续改值;不要求抓住滑块</li>
|
||
<li>手势是「按下 + 拖动」一次连续动作,不响应长按或双击;横向分量优先,纵向滚动不被吞掉(touch-action: pan-y)</li>
|
||
<li>取值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值、右值不越过左值</li>
|
||
<li>拖动期间滑块放大到 24px,松手后 150ms 回弹;减少动态偏好下瞬时切换</li>
|
||
<li>值变化立即触发 change 事件(连续回传),不做防抖;松手时才做业务提交</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="a11y">无障碍</h2>
|
||
<ul>
|
||
<li>单滑块:轨道热区是 role="slider" 且可聚焦,写 aria-valuemin / aria-valuemax / aria-valuenow / aria-label</li>
|
||
<li>双滑块:外层是 role="group" + aria-label,每个滑块各自持有 aria-valuenow(两条独立数值)</li>
|
||
<li>禁用时写 aria-disabled="true",读屏会播报不可用</li>
|
||
<li>键盘:聚焦后左右方向键按 step 步进(触屏之外的替代路径)</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-stepper.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;">Stepper</em></td><td>有界区间里加减小整数、且需要精确值时用 Stepper;数值连续、更看重「大致位置」时才用 Slider</td></tr>
|
||
<tr><td><a href="mobile-input.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;">Input</em></td><td>需要键盘输入精确数值(金额、编号)时用 Input + 数字键盘;Slider 只适合粗选</td></tr>
|
||
<tr><td><a href="mobile-form.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;">Form</em></td><td>滑块作为表单里的一个字段时,标签、必填星号与错误提示由 Form 的 item 提供</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>数值输入框与滑块的双向绑定(宿主自行组合 Input,本组件只回传值)</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>双滑块的最小间距是否应当是 1 个 step(当前是),还是用户可配</td></tr>
|
||
<tr><td><span class="m-chip">规格未定</span></td><td>刻度数量是否应由 step 推导(当前按 5 等分固定标点)</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>slider</code></td><td>根元素,一行里放进「轨道热区 + 数值」</td></tr>
|
||
<tr><td><code>rail</code></td><td>轨道热区,44px 高的可聚焦区域,承载指针事件与锚点</td></tr>
|
||
<tr><td><code>track</code></td><td>轨道本体(可视 4px),承载选中段与刻度的边界</td></tr>
|
||
<tr><td><code>filled</code></td><td>选中段,从起点铺到滑块位置</td></tr>
|
||
<tr><td><code>thumb</code></td><td>滑块,位置由当前值的百分比决定;拖动中放大作为「抓住了」的反馈</td></tr>
|
||
<tr><td><code>mark</code></td><td>刻度点,仅 marks=with-marks 时出现(只标位置,不写文案)</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>single / range</td><td><div><b>single</b> <span class="m-none">(由数据驱动,无专属类)</span></div><div><b>range</b> <span class="m-cls">.kole-m-slider--range</span></div></td></tr>
|
||
<tr><td><code>marks</code></td><td>with-marks / none</td><td><div><b>with-marks</b> <span class="m-cls">.kole-m-slider--marks</span> <span class="m-cls">.kole-m-slider__mark</span></div><div><b>none</b> <span class="m-none">(由数据驱动,无专属类)</span></div></td></tr>
|
||
<tr><td><code>showValue</code></td><td>true / false</td><td><div><b>true</b> <span class="m-none">(由数据驱动,无专属类)</span></div><div><b>false</b> <span class="m-none">(由数据驱动,无专属类)</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=single · marks=none · showValue=true</code></td><td>默认(单滑块 + 右侧数值)</td></tr>
|
||
<tr><td><code>mode=single · marks=with-marks · showValue=true</code></td><td>带刻度(轨道上标出等分点)</td></tr>
|
||
<tr><td><code>mode=range · marks=none · showValue=true</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-font-size-body</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="可点区域最小边长">--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-disabled-bg</span>
|
||
<span class="m-chip inherit" title="输入框聚焦外发光">--kole-color-focus-ring</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="">--kole-ease-standard</span>
|
||
<span class="m-chip inherit" title="卡片默认">--kole-shadow-low</span>
|
||
<span class="m-chip inherit" title="">--kole-space-12</span>
|
||
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-slider-thumb</span>
|
||
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-slider-thumb-active</span>
|
||
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-slider-track</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="sources">6 端源码</h2>
|
||
<p class="m-sub">同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。</p>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Slider.css · 纯样式(CSS) · 135 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Slider.css</span><button class="m-copy" type="button" data-copy="code-211">复制</button></div>
|
||
<pre id="code-211"><code>/* Kole UI Mobile · Slider 样式 — 对齐移动端规格 §35
|
||
滑动选择器:轨道可视高度只有 4px,但**整条热区高度是 44px**(触控最小边长)——
|
||
直接点轨道上的任意位置也要能跳到该位置,不能要求用户精确抓住滑块。
|
||
滑块(thumb)视觉 20px,按下/拖动时放大到 24px 作为「手指抓住了」的反馈。 */
|
||
|
||
.kole-m-slider {
|
||
/* 组件级变量:业务侧可在容器上覆盖 */
|
||
--kole-m-slider-thumb: 20px; /* 滑块视觉直径 */
|
||
--kole-m-slider-thumb-active: 24px; /* 拖动中的滑块直径 */
|
||
--kole-m-slider-track: 4px; /* 轨道可视高度 */
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--kole-space-12);
|
||
width: 100%;
|
||
padding: 0 var(--kole-m-gutter);
|
||
background: var(--kole-color-card-bg);
|
||
color: var(--kole-color-text-body);
|
||
font-family: inherit;
|
||
font-size: var(--kole-m-font-size-body);
|
||
line-height: 1.4;
|
||
/* 注意:根元素**不写** cursor: pointer —— 数值文案是显示而不是控件,
|
||
整条挂 pointer 会让它看起来可点(键盘可达性判据也会因此报「鼠标专用交互」)。
|
||
可点的语义由 .kole-m-slider__rail(role="slider" + tabindex)承担。 */
|
||
cursor: default;
|
||
touch-action: pan-y;
|
||
}
|
||
|
||
/* 轨道热区:可视 4px 的轨道垂直居中在这条 44px 高的带子里 */
|
||
.kole-m-slider__rail {
|
||
position: relative;
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: var(--kole-m-touch-target);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.kole-m-slider__track {
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
height: var(--kole-m-slider-track);
|
||
border-radius: calc(var(--kole-m-slider-track) / 2);
|
||
background: var(--kole-color-border);
|
||
}
|
||
|
||
/* 选中段:从起点铺到滑块位置(宽度由 left/right 内联样式给出) */
|
||
.kole-m-slider__filled {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
border-radius: inherit;
|
||
background: var(--kole-color-brand);
|
||
}
|
||
|
||
/* 滑块:白底 + 品牌描边 + 投影,保证在浅色轨道上也看得清 */
|
||
.kole-m-slider__thumb {
|
||
position: absolute;
|
||
top: 50%;
|
||
box-sizing: border-box;
|
||
width: var(--kole-m-slider-thumb);
|
||
height: var(--kole-m-slider-thumb);
|
||
margin-top: calc(var(--kole-m-slider-thumb) / -2);
|
||
margin-left: calc(var(--kole-m-slider-thumb) / -2);
|
||
border: 2px solid var(--kole-color-brand);
|
||
border-radius: 50%;
|
||
background: var(--kole-color-card-bg);
|
||
box-shadow: var(--kole-shadow-low);
|
||
transition: width 150ms var(--kole-ease-standard), height 150ms var(--kole-ease-standard),
|
||
margin 150ms var(--kole-ease-standard);
|
||
}
|
||
|
||
/* 状态 dragging:滑块放大(手指抓住的反馈),并在整条轨道上加一层浅底 */
|
||
.kole-m-slider.is-dragging .kole-m-slider__thumb {
|
||
width: var(--kole-m-slider-thumb-active);
|
||
height: var(--kole-m-slider-thumb-active);
|
||
margin-top: calc(var(--kole-m-slider-thumb-active) / -2);
|
||
margin-left: calc(var(--kole-m-slider-thumb-active) / -2);
|
||
}
|
||
|
||
.kole-m-slider.is-dragging .kole-m-slider__rail { background: var(--kole-color-brand-bg); }
|
||
|
||
/* 双滑块(mode=range):两个滑块共用同一个类,位置由内联 left 区分 */
|
||
.kole-m-slider--range .kole-m-slider__thumb { background: var(--kole-color-card-bg); }
|
||
|
||
/* 数值显示:当前值跟在轨道右侧(宽度固定,避免拖动时文字宽度变化导致轨道抖动) */
|
||
.kole-m-slider__value {
|
||
flex: 0 0 auto;
|
||
min-width: 3em;
|
||
text-align: end;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--kole-color-text-secondary);
|
||
font-size: var(--kole-m-font-size-label);
|
||
}
|
||
|
||
/* 变体 marks=with-marks:轨道上出现刻度点(只标位置,不写文案,避免小屏拥挤)。
|
||
默认不渲染刻度点 —— 刻度是「有意义的等分点」而非装饰,没有它时轨道更干净。 */
|
||
.kole-m-slider__mark {
|
||
display: none;
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 2px;
|
||
height: 8px;
|
||
margin-top: -4px;
|
||
margin-left: -1px;
|
||
border-radius: 1px;
|
||
background: var(--kole-color-text-placeholder);
|
||
}
|
||
|
||
.kole-m-slider--marks .kole-m-slider__mark { display: block; }
|
||
|
||
/* 状态 disabled:整条置灰且不响应(滑块不再有描边) */
|
||
.kole-m-slider.is-disabled {
|
||
cursor: not-allowed;
|
||
color: var(--kole-color-text-disabled);
|
||
}
|
||
|
||
.kole-m-slider.is-disabled .kole-m-slider__thumb {
|
||
border-color: var(--kole-color-text-disabled);
|
||
background: var(--kole-color-disabled-bg);
|
||
}
|
||
|
||
.kole-m-slider.is-disabled .kole-m-slider__filled { background: var(--kole-color-text-disabled); }
|
||
|
||
.kole-m-slider.is-disabled .kole-m-slider__value { color: var(--kole-color-text-disabled); }
|
||
|
||
.kole-m-slider:focus-visible {
|
||
outline: 2px solid var(--kole-color-focus-ring);
|
||
outline-offset: -2px;
|
||
}
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Slider.html · H5 原生(无框架) · 254 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Slider.html</span><button class="m-copy" type="button" data-copy="code-212">复制</button></div>
|
||
<pre id="code-212"><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 · Slider(H5)</title>
|
||
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
|
||
<link rel="stylesheet" href="Slider.css">
|
||
<style>
|
||
body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
|
||
.demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
|
||
.demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
|
||
font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
|
||
.demo-pad { padding-bottom: var(--kole-space-16); background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
|
||
.demo-note { margin: 0; padding: 0 var(--kole-m-gutter) var(--kole-space-12);
|
||
font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
|
||
.demo-block[hidden] { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="demo">
|
||
<section class="demo-block" data-demo="basic">
|
||
<p class="demo-label">基础用法(在轨道上按住拖动即改值;轨道可视 4px、热区 44px 高)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider" id="sl-basic" data-min="0" data-max="100" data-step="1" data-value="40"
|
||
data-assert="slider-basic" data-behavior="swipe-sets-class:#sl-basic|is-dragging">
|
||
<span class="kole-m-slider__rail" role="slider" tabindex="0"
|
||
aria-label="预算上限" aria-valuemin="0" aria-valuemax="100" aria-valuenow="40">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="40"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">40</span>
|
||
</div>
|
||
</div>
|
||
<p class="demo-note">在轨道上按住即跳到手指处,横向拖动连续改值;松手后滑块 150ms 回弹到常态尺寸。</p>
|
||
</section>
|
||
|
||
<section class="demo-block" data-demo="steps">
|
||
<p class="demo-label">带刻度(marks=with-marks:轨道上标出等分点)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider kole-m-slider--marks" id="sl-marks" data-min="0" data-max="100" data-step="1" data-value="60"
|
||
data-assert="slider-marks">
|
||
<span class="kole-m-slider__rail" role="slider" tabindex="0"
|
||
aria-label="亮度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__mark" style="left: 25%"></span>
|
||
<span class="kole-m-slider__mark" style="left: 50%"></span>
|
||
<span class="kole-m-slider__mark" style="left: 75%"></span>
|
||
<span class="kole-m-slider__thumb" data-value="60"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">60</span>
|
||
</div>
|
||
</div>
|
||
<p class="demo-note">刻度只是位置提示;取值仍按 data-step 吸附(默认 1),不会停在刻度之间。</p>
|
||
</section>
|
||
|
||
<section class="demo-block" data-demo="range">
|
||
<p class="demo-label">双滑块(mode=range:两个滑块各自独立拖动,左值不会越过右值)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider kole-m-slider--range" id="sl-range" data-min="0" data-max="100" data-step="1"
|
||
data-value="20" data-value-max="70" data-assert="slider-range">
|
||
<span class="kole-m-slider__rail" role="group" aria-label="价格区间" tabindex="0">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="20"></span>
|
||
<span class="kole-m-slider__thumb" data-value="70"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">20~70</span>
|
||
</div>
|
||
</div>
|
||
<p class="demo-note">两个滑块各有自己的 aria-valuenow;拖动时按「离手指更近」判定改哪一个。</p>
|
||
</section>
|
||
|
||
<section class="demo-block" data-demo="dragging">
|
||
<p class="demo-label">拖动中(滑块由 20px 放大到 24px,同时给轨道加一层浅底作「抓住了」的反馈)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider is-dragging" data-min="0" data-max="100" data-step="1" data-value="75"
|
||
data-assert="slider-dragging">
|
||
<span class="kole-m-slider__rail" role="slider" tabindex="0"
|
||
aria-label="音量" aria-valuemin="0" aria-valuemax="100" aria-valuenow="75">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="75"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">75</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="demo-block" data-demo="disabled">
|
||
<p class="demo-label">禁用(整条置灰且不响应:读屏会播报不可用,指针事件不再改值)</p>
|
||
<div class="demo-pad">
|
||
<div class="kole-m-slider is-disabled" data-min="0" data-max="100" data-value="30"
|
||
aria-disabled="true" data-assert="slider-disabled">
|
||
<span class="kole-m-slider__rail" role="slider" aria-label="热度权重"
|
||
aria-valuemin="0" aria-valuemax="100" aria-valuenow="30" aria-disabled="true">
|
||
<span class="kole-m-slider__track">
|
||
<span class="kole-m-slider__filled"></span>
|
||
<span class="kole-m-slider__thumb" data-value="30"></span>
|
||
</span>
|
||
</span>
|
||
<span class="kole-m-slider__value">30</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<script>
|
||
/* 演示页脚本:真实拖动。
|
||
- 浏览器端用 Pointer Events:pointerdown 落在轨道热区上即跳到该处,pointermove 跟随
|
||
- 值按 data-step(默认 1)吸附并夹在 [min, max];双滑块时左值不越过右值
|
||
- 拖动期间(含松手后 150ms 回弹)根元素带 is-dragging
|
||
uni-app 端同一手势用 @touchstart/@touchmove/@touchend 实现(小程序没有 PointerEvent)。
|
||
真实业务里这份状态由宿主管理(受控 value + change 事件),此处是最小可运行实现。 */
|
||
(function () {
|
||
/* 松手后滑块以 150ms 回弹到常态尺寸,这段过渡期间保持 is-dragging(避免过渡被打断) */
|
||
var REBOUND = 150;
|
||
|
||
Array.prototype.forEach.call(document.querySelectorAll('.kole-m-slider'), function (root) {
|
||
if (root.classList.contains('is-disabled') || root.getAttribute('aria-disabled') === 'true') return;
|
||
var rail = root.querySelector('.kole-m-slider__rail');
|
||
var track = root.querySelector('.kole-m-slider__track');
|
||
var filled = root.querySelector('.kole-m-slider__filled');
|
||
var valueEl = root.querySelector('.kole-m-slider__value');
|
||
var thumbs = Array.prototype.slice.call(root.querySelectorAll('.kole-m-slider__thumb'));
|
||
if (!rail || !track || !filled || !thumbs.length) return;
|
||
|
||
var min = Number(root.getAttribute('data-min') || 0);
|
||
var max = Number(root.getAttribute('data-max') || 100);
|
||
var step = Number(root.getAttribute('data-step') || 1);
|
||
var range = thumbs.length > 1;
|
||
var active = -1;
|
||
var timer = null;
|
||
|
||
function clamp(v) {
|
||
var snapped = Math.round((v - min) / step) * step + min;
|
||
return Math.max(min, Math.min(max, snapped));
|
||
}
|
||
function ratio(v) { return max === min ? 0 : (v - min) / (max - min); }
|
||
function valueAt(clientX) {
|
||
var rect = track.getBoundingClientRect();
|
||
if (!rect.width) return min;
|
||
return clamp(min + ((clientX - rect.left) / rect.width) * (max - min));
|
||
}
|
||
function values() {
|
||
return thumbs.map(function (t) { return Number(t.getAttribute('data-value') || min); });
|
||
}
|
||
function render() {
|
||
var v = values();
|
||
thumbs.forEach(function (t, i) { t.style.left = (ratio(v[i]) * 100) + '%'; });
|
||
if (range) {
|
||
filled.style.left = (ratio(v[0]) * 100) + '%';
|
||
filled.style.right = (100 - ratio(v[1]) * 100) + '%';
|
||
if (valueEl) valueEl.textContent = v[0] + '~' + v[1];
|
||
root.setAttribute('data-value', String(v[0]));
|
||
root.setAttribute('data-value-max', String(v[1]));
|
||
thumbs[0].setAttribute('aria-valuenow', String(v[0]));
|
||
thumbs[1].setAttribute('aria-valuenow', String(v[1]));
|
||
} else {
|
||
filled.style.right = (100 - ratio(v[0]) * 100) + '%';
|
||
if (valueEl) valueEl.textContent = String(v[0]);
|
||
root.setAttribute('data-value', String(v[0]));
|
||
rail.setAttribute('aria-valuenow', String(v[0]));
|
||
}
|
||
}
|
||
function nearest(v) {
|
||
if (!range) return 0;
|
||
var cur = values();
|
||
return Math.abs(cur[0] - v) <= Math.abs(cur[1] - v) ? 0 : 1;
|
||
}
|
||
function setValue(index, v) {
|
||
/* 双滑块:左值不越过右值、右值不越过左值(各留一个 step 的间距) */
|
||
if (range) {
|
||
var other = values()[1 - index];
|
||
v = index === 0 ? Math.min(v, other - step) : Math.max(v, other + step);
|
||
}
|
||
thumbs[index].setAttribute('data-value', String(clamp(v)));
|
||
render();
|
||
}
|
||
|
||
function start(e) {
|
||
/* 值区只显示数字、不接受拖动(它是显示而不是控件) */
|
||
if (e.target && e.target.closest && e.target.closest('.kole-m-slider__value')) return;
|
||
var v = valueAt(e.clientX);
|
||
active = nearest(v);
|
||
if (timer) { clearTimeout(timer); timer = null; }
|
||
root.classList.add('is-dragging');
|
||
setValue(active, v);
|
||
if (root.setPointerCapture) { try { root.setPointerCapture(e.pointerId); } catch (err) { /* 忽略 */ } }
|
||
}
|
||
function move(e) {
|
||
if (active < 0) return;
|
||
setValue(active, valueAt(e.clientX));
|
||
}
|
||
function end() {
|
||
if (active < 0) return;
|
||
active = -1;
|
||
timer = setTimeout(function () {
|
||
root.classList.remove('is-dragging');
|
||
timer = null;
|
||
}, REBOUND);
|
||
}
|
||
|
||
/* 手势挂在根元素上:整个控件(含轨道两侧留白)都是有效起点,符合「44px 热区」的意图;
|
||
行为断言库的 swipe-sets-class 也把事件派发在根元素上(同一契约)。 */
|
||
root.addEventListener('pointerdown', start);
|
||
root.addEventListener('pointermove', move);
|
||
root.addEventListener('pointerup', end);
|
||
root.addEventListener('pointercancel', end);
|
||
|
||
/* 键盘:单滑块的左右方向键按 step 步进(滑条热区可聚焦,靠 tabindex 进键盘序列) */
|
||
root.addEventListener('keydown', function (e) {
|
||
if (range) return;
|
||
var delta = e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step
|
||
: e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step : 0;
|
||
if (!delta) return;
|
||
e.preventDefault();
|
||
setValue(0, values()[0] + delta);
|
||
});
|
||
|
||
/* 初始值:双滑块从两根 thumb 的 data-value 读,单滑块从根节点的 data-value 读 */
|
||
if (!range) thumbs[0].setAttribute('data-value', root.getAttribute('data-value') || String(min));
|
||
render();
|
||
});
|
||
})();
|
||
</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/Slider.jsx · React · 145 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Slider.jsx</span><button class="m-copy" type="button" data-copy="code-213">复制</button></div>
|
||
<pre id="code-213"><code>import React, { useRef, useState } from 'react';
|
||
import './Slider.css';
|
||
|
||
/* 滑动选择器(移动端)— 规格 §35
|
||
轨道可视高度只有 4px,但**整条热区高度是 44px**:直接点轨道任意位置也要能跳到该处。
|
||
浏览器端手势用 Pointer Events(pointerdown / pointermove / pointerup + setPointerCapture);
|
||
uni-app 端同一手势改用 @touch*(小程序与 App 端没有 PointerEvent)。
|
||
值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值。
|
||
受控:本端不存储值,只回传目标值 onChange,是否采纳由宿主决定。 */
|
||
|
||
export default function Slider({
|
||
value = 0,
|
||
valueMax = 100,
|
||
min = 0,
|
||
max = 100,
|
||
step = 1,
|
||
mode = 'single',
|
||
marks = false,
|
||
showValue = true,
|
||
disabled = false,
|
||
label = '滑动选择',
|
||
onChange,
|
||
children = null,
|
||
}) {
|
||
const railRef = useRef(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const [active, setActive] = useState(-1);
|
||
/* 双滑块的两个值;单滑块只用到第一个 */
|
||
const [inner, setInner] = useState(null);
|
||
const values = inner || (mode === 'range' ? [value, valueMax] : [value]);
|
||
const range = mode === 'range';
|
||
|
||
const cls =
|
||
'kole-m-slider' +
|
||
(range ? ' kole-m-slider--range' : '') +
|
||
(marks ? ' kole-m-slider--marks' : '') +
|
||
(dragging ? ' is-dragging' : '') +
|
||
(disabled ? ' is-disabled' : '');
|
||
|
||
const ratio = (v) => (max === min ? 0 : (v - min) / (max - min));
|
||
const clamp = (v) => {
|
||
const snapped = Math.round((v - min) / step) * step + min;
|
||
return Math.max(min, Math.min(max, snapped));
|
||
};
|
||
|
||
function valueAt(clientX) {
|
||
const el = railRef.current;
|
||
if (!el) return min;
|
||
const rect = el.getBoundingClientRect();
|
||
if (!rect.width) return min;
|
||
return clamp(min + ((clientX - rect.left) / rect.width) * (max - min));
|
||
}
|
||
|
||
function nearest(v) {
|
||
if (!range) return 0;
|
||
return Math.abs(values[0] - v) <= Math.abs(values[1] - v) ? 0 : 1;
|
||
}
|
||
|
||
function commit(index, v) {
|
||
/* 双滑块:左值不越过右值、右值不越过左值(各留一个 step 的间距) */
|
||
let next = clamp(v);
|
||
if (range) {
|
||
const other = values[1 - index];
|
||
next = index === 0 ? Math.min(next, other - step) : Math.max(next, other + step);
|
||
}
|
||
const out = range ? [values[0], values[1]] : [next];
|
||
out[index] = next;
|
||
setInner(out);
|
||
if (onChange) onChange(range ? out : out[0]);
|
||
}
|
||
|
||
/* 手势起止都放在根元素上:整个控件(含两侧留白)都是有效起点 */
|
||
function onPointerDown(e) {
|
||
if (disabled) return;
|
||
if (e.target && e.target.closest && e.target.closest('.kole-m-slider__value')) return;
|
||
const v = valueAt(e.clientX);
|
||
setActive(nearest(v));
|
||
setDragging(true);
|
||
commit(nearest(v), v);
|
||
const el = railRef.current;
|
||
if (el && el.setPointerCapture) {
|
||
try {
|
||
el.setPointerCapture(e.pointerId);
|
||
} catch (err) {
|
||
/* 指针捕获不可用时靠冒泡事件继续跟随 */
|
||
}
|
||
}
|
||
}
|
||
|
||
function onPointerMove(e) {
|
||
if (active < 0) return;
|
||
commit(active, valueAt(e.clientX));
|
||
}
|
||
|
||
function onPointerUp() {
|
||
if (active < 0) return;
|
||
setActive(-1);
|
||
setDragging(false);
|
||
}
|
||
|
||
function onKeyDown(e) {
|
||
if (disabled || range) return;
|
||
const delta =
|
||
e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step : e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step : 0;
|
||
if (!delta) return;
|
||
e.preventDefault();
|
||
commit(0, values[0] + delta);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cls}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onPointerCancel={onPointerUp}
|
||
onKeyDown={onKeyDown}
|
||
>
|
||
<span className="kole-m-slider__rail" role={range ? 'group' : 'slider'} tabIndex={disabled ? undefined : 0} ref={railRef} aria-label={label} aria-valuemin={min} aria-valuemax={max} aria-valuenow={range ? undefined : values[0]}>
|
||
<span className="kole-m-slider__track">
|
||
<span
|
||
className="kole-m-slider__filled"
|
||
style={range ? { left: ratio(values[0]) * 100 + '%', right: 100 - ratio(values[1]) * 100 + '%' } : undefined}
|
||
/>
|
||
{/* 刻度点:marks=true 时按 step 的可整除位置画(只标位置,不写文案) */}
|
||
{marks
|
||
? [0, 1, 2, 3, 4].map((i) => (
|
||
<span className="kole-m-slider__mark" key={'m' + i} style={{ left: i * 25 + '%' }} />
|
||
))
|
||
: null}
|
||
{range
|
||
? values.map((v, i) => (
|
||
<span className="kole-m-slider__thumb" key={i} style={{ left: ratio(v) * 100 + '%' }} />
|
||
))
|
||
: <span className="kole-m-slider__thumb" style={{ left: ratio(values[0]) * 100 + '%' }} />}
|
||
</span>
|
||
</span>
|
||
{showValue ? (
|
||
<span className="kole-m-slider__value">{range ? values[0] + '~' + values[1] : values[0]}</span>
|
||
) : null}
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Slider.vue2.vue · Vue 2 · 170 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Slider.vue2.vue</span><button class="m-copy" type="button" data-copy="code-214">复制</button></div>
|
||
<pre id="code-214"><code><template>
|
||
<div
|
||
class="kole-m-slider"
|
||
:class="sliderClass"
|
||
@pointerdown="onPointerDown"
|
||
@pointermove="onPointerMove"
|
||
@pointerup="onPointerUp"
|
||
@pointercancel="onPointerUp"
|
||
@keydown="onKeyDown"
|
||
>
|
||
<span
|
||
ref="rail"
|
||
class="kole-m-slider__rail"
|
||
:role="range ? 'group' : 'slider'"
|
||
:tabindex="disabled ? null : 0"
|
||
:aria-label="label"
|
||
:aria-valuemin="min"
|
||
:aria-valuemax="max"
|
||
:aria-valuenow="range ? null : values[0]"
|
||
>
|
||
<span class="kole-m-slider__track">
|
||
<span
|
||
class="kole-m-slider__filled"
|
||
:style="range ? { left: pct(values[0]), right: 100 - parseFloat(pct(values[1])) + '%' } : null"
|
||
></span>
|
||
<template v-if="marks">
|
||
<span
|
||
v-for="m in markPoints"
|
||
:key="'m' + m"
|
||
class="kole-m-slider__mark"
|
||
:style="{ left: m + '%' }"
|
||
></span>
|
||
</template>
|
||
<span
|
||
v-for="(v, i) in values"
|
||
:key="'t' + i"
|
||
class="kole-m-slider__thumb"
|
||
:style="{ left: pct(v) }"
|
||
></span>
|
||
</span>
|
||
</span>
|
||
<span v-if="showValue" class="kole-m-slider__value">{{ range ? values[0] + '~' + values[1] : values[0] }}</span>
|
||
<slot></slot>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
/* 滑动选择器(移动端)— 规格 §35
|
||
轨道可视高度只有 4px,但**整条热区高度是 44px**:直接点轨道任意位置也要能跳到该处。
|
||
浏览器端手势用 Pointer Events(pointerdown / pointermove / pointerup + setPointerCapture);
|
||
uni-app 端同一手势改用 @touch*(小程序与 App 端没有 PointerEvent)。
|
||
值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值。
|
||
受控:本端不存储值,只回传目标值 change,是否采纳由宿主决定。 */
|
||
|
||
export default {
|
||
name: 'KoleMSlider',
|
||
props: {
|
||
value: { type: Number, default: 0 },
|
||
valueMax: { type: Number, default: 100 },
|
||
min: { type: Number, default: 0 },
|
||
max: { type: Number, default: 100 },
|
||
step: { type: Number, default: 1 },
|
||
mode: { type: String, default: 'single' },
|
||
marks: { type: Boolean, default: false },
|
||
showValue: { type: Boolean, default: true },
|
||
disabled: { type: Boolean, default: false },
|
||
label: { type: String, default: '滑动选择' }
|
||
},
|
||
data: function () {
|
||
return {
|
||
/* null = 跟随 value 属性;拖动后本地记账(与 Collapse 同一约定) */
|
||
inner: null,
|
||
dragging: false,
|
||
active: -1,
|
||
markPoints: [0, 25, 50, 75, 100]
|
||
};
|
||
},
|
||
computed: {
|
||
range: function () {
|
||
return this.mode === 'range';
|
||
},
|
||
values: function () {
|
||
if (this.inner) return this.inner;
|
||
return this.range ? [this.value, this.valueMax] : [this.value];
|
||
},
|
||
sliderClass: function () {
|
||
return [
|
||
this.range ? 'kole-m-slider--range' : '',
|
||
this.marks ? 'kole-m-slider--marks' : '',
|
||
this.dragging ? 'is-dragging' : '',
|
||
this.disabled ? 'is-disabled' : ''
|
||
].filter(Boolean);
|
||
}
|
||
},
|
||
methods: {
|
||
pct: function (v) {
|
||
if (this.max === this.min) return '0%';
|
||
return ((v - this.min) / (this.max - this.min)) * 100 + '%';
|
||
},
|
||
clamp: function (v) {
|
||
var snapped = Math.round((v - this.min) / this.step) * this.step + this.min;
|
||
return Math.max(this.min, Math.min(this.max, snapped));
|
||
},
|
||
valueAt: function (clientX) {
|
||
var el = this.$refs.rail;
|
||
if (!el) return this.min;
|
||
var rect = el.getBoundingClientRect();
|
||
if (!rect.width) return this.min;
|
||
return this.clamp(this.min + ((clientX - rect.left) / rect.width) * (this.max - this.min));
|
||
},
|
||
nearest: function (v) {
|
||
if (!this.range) return 0;
|
||
return Math.abs(this.values[0] - v) <= Math.abs(this.values[1] - v) ? 0 : 1;
|
||
},
|
||
commit: function (index, v) {
|
||
/* 双滑块:左值不越过右值、右值不越过左值(各留一个 step 的间距) */
|
||
var next = this.clamp(v);
|
||
if (this.range) {
|
||
var other = this.values[1 - index];
|
||
next = index === 0 ? Math.min(next, other - this.step) : Math.max(next, other + this.step);
|
||
}
|
||
var out = this.range ? [this.values[0], this.values[1]] : [next];
|
||
out[index] = next;
|
||
this.inner = out;
|
||
this.$emit('change', this.range ? out : out[0]);
|
||
},
|
||
/* 手势起止都放在根元素上:整个控件(含两侧留白)都是有效起点 */
|
||
onPointerDown: function (e) {
|
||
if (this.disabled) return;
|
||
if (e.target && e.target.closest && e.target.closest('.kole-m-slider__value')) return;
|
||
var v = this.valueAt(e.clientX);
|
||
this.active = this.nearest(v);
|
||
this.dragging = true;
|
||
this.commit(this.nearest(v), v);
|
||
var el = this.$refs.rail;
|
||
if (el && el.setPointerCapture) {
|
||
try {
|
||
el.setPointerCapture(e.pointerId);
|
||
} catch (err) {
|
||
/* 指针捕获不可用时靠冒泡事件继续跟随 */
|
||
}
|
||
}
|
||
},
|
||
onPointerMove: function (e) {
|
||
if (this.active < 0) return;
|
||
this.commit(this.active, this.valueAt(e.clientX));
|
||
},
|
||
onPointerUp: function () {
|
||
if (this.active < 0) return;
|
||
this.active = -1;
|
||
this.dragging = false;
|
||
},
|
||
onKeyDown: function (e) {
|
||
if (this.disabled || this.range) return;
|
||
var delta =
|
||
e.key === 'ArrowLeft' || e.key === 'ArrowDown'
|
||
? -this.step
|
||
: e.key === 'ArrowRight' || e.key === 'ArrowUp'
|
||
? this.step
|
||
: 0;
|
||
if (!delta) return;
|
||
e.preventDefault();
|
||
this.commit(0, this.values[0] + delta);
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style src="./Slider.css"></style>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Slider.vue3.vue · Vue 3 · 156 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Slider.vue3.vue</span><button class="m-copy" type="button" data-copy="code-215">复制</button></div>
|
||
<pre id="code-215"><code><template>
|
||
<div
|
||
class="kole-m-slider"
|
||
:class="sliderClass"
|
||
@pointerdown="onPointerDown"
|
||
@pointermove="onPointerMove"
|
||
@pointerup="onPointerUp"
|
||
@pointercancel="onPointerUp"
|
||
@keydown="onKeyDown"
|
||
>
|
||
<span
|
||
ref="rail"
|
||
class="kole-m-slider__rail"
|
||
:role="range ? 'group' : 'slider'"
|
||
:tabindex="disabled ? null : 0"
|
||
:aria-label="label"
|
||
:aria-valuemin="min"
|
||
:aria-valuemax="max"
|
||
:aria-valuenow="range ? null : values[0]"
|
||
>
|
||
<span class="kole-m-slider__track">
|
||
<span
|
||
class="kole-m-slider__filled"
|
||
:style="range ? { left: pct(values[0]), right: 100 - parseFloat(pct(values[1])) + '%' } : null"
|
||
></span>
|
||
<template v-if="marks">
|
||
<span
|
||
v-for="m in markPoints"
|
||
:key="'m' + m"
|
||
class="kole-m-slider__mark"
|
||
:style="{ left: m + '%' }"
|
||
></span>
|
||
</template>
|
||
<span
|
||
v-for="(v, i) in values"
|
||
:key="'t' + i"
|
||
class="kole-m-slider__thumb"
|
||
:style="{ left: pct(v) }"
|
||
></span>
|
||
</span>
|
||
</span>
|
||
<span v-if="showValue" class="kole-m-slider__value">{{ range ? values[0] + '~' + values[1] : values[0] }}</span>
|
||
<slot></slot>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
/* 滑动选择器(移动端)— 规格 §35
|
||
轨道可视高度只有 4px,但**整条热区高度是 44px**:直接点轨道任意位置也要能跳到该处。
|
||
浏览器端手势用 Pointer Events(pointerdown / pointermove / pointerup + setPointerCapture);
|
||
uni-app 端同一手势改用 @touch*(小程序与 App 端没有 PointerEvent)。
|
||
值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值。
|
||
受控:本端不存储值,只回传目标值 change,是否采纳由宿主决定。 */
|
||
import { computed, ref } from 'vue';
|
||
|
||
const props = defineProps({
|
||
value: { type: Number, default: 0 },
|
||
valueMax: { type: Number, default: 100 },
|
||
min: { type: Number, default: 0 },
|
||
max: { type: Number, default: 100 },
|
||
step: { type: Number, default: 1 },
|
||
mode: { type: String, default: 'single' },
|
||
marks: { type: Boolean, default: false },
|
||
showValue: { type: Boolean, default: true },
|
||
disabled: { type: Boolean, default: false },
|
||
label: { type: String, default: '滑动选择' }
|
||
});
|
||
const emit = defineEmits(['change']);
|
||
|
||
const rail = ref(null);
|
||
const dragging = ref(false);
|
||
const active = ref(-1);
|
||
/* null = 跟随 value 属性;拖动后本地记账(与 Collapse 同一约定) */
|
||
const inner = ref(null);
|
||
|
||
const range = computed(() => props.mode === 'range');
|
||
const values = computed(() => inner.value || (range.value ? [props.value, props.valueMax] : [props.value]));
|
||
const markPoints = [0, 25, 50, 75, 100];
|
||
|
||
const sliderClass = computed(() => [
|
||
range.value ? 'kole-m-slider--range' : '',
|
||
props.marks ? 'kole-m-slider--marks' : '',
|
||
dragging.value ? 'is-dragging' : '',
|
||
props.disabled ? 'is-disabled' : ''
|
||
].filter(Boolean));
|
||
|
||
function pct(v) {
|
||
if (props.max === props.min) return '0%';
|
||
return ((v - props.min) / (props.max - props.min)) * 100 + '%';
|
||
}
|
||
function clamp(v) {
|
||
const snapped = Math.round((v - props.min) / props.step) * props.step + props.min;
|
||
return Math.max(props.min, Math.min(props.max, snapped));
|
||
}
|
||
function valueAt(clientX) {
|
||
const el = rail.value;
|
||
if (!el) return props.min;
|
||
const rect = el.getBoundingClientRect();
|
||
if (!rect.width) return props.min;
|
||
return clamp(props.min + ((clientX - rect.left) / rect.width) * (props.max - props.min));
|
||
}
|
||
function nearest(v) {
|
||
if (!range.value) return 0;
|
||
return Math.abs(values.value[0] - v) <= Math.abs(values.value[1] - v) ? 0 : 1;
|
||
}
|
||
function commit(index, v) {
|
||
/* 双滑块:左值不越过右值、右值不越过左值(各留一个 step 的间距) */
|
||
let next = clamp(v);
|
||
if (range.value) {
|
||
const other = values.value[1 - index];
|
||
next = index === 0 ? Math.min(next, other - props.step) : Math.max(next, other + props.step);
|
||
}
|
||
const out = range.value ? [values.value[0], values.value[1]] : [next];
|
||
out[index] = next;
|
||
inner.value = out;
|
||
emit('change', range.value ? out : out[0]);
|
||
}
|
||
|
||
/* 手势起止都放在根元素上:整个控件(含两侧留白)都是有效起点 */
|
||
function onPointerDown(e) {
|
||
if (props.disabled) return;
|
||
if (e.target && e.target.closest && e.target.closest('.kole-m-slider__value')) return;
|
||
const v = valueAt(e.clientX);
|
||
active.value = nearest(v);
|
||
dragging.value = true;
|
||
commit(nearest(v), v);
|
||
const el = rail.value;
|
||
if (el && el.setPointerCapture) {
|
||
try {
|
||
el.setPointerCapture(e.pointerId);
|
||
} catch (err) {
|
||
/* 指针捕获不可用时靠冒泡事件继续跟随 */
|
||
}
|
||
}
|
||
}
|
||
function onPointerMove(e) {
|
||
if (active.value < 0) return;
|
||
commit(active.value, valueAt(e.clientX));
|
||
}
|
||
function onPointerUp() {
|
||
if (active.value < 0) return;
|
||
active.value = -1;
|
||
dragging.value = false;
|
||
}
|
||
function onKeyDown(e) {
|
||
if (props.disabled || range.value) return;
|
||
const delta =
|
||
e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -props.step : e.key === 'ArrowRight' || e.key === 'ArrowUp' ? props.step : 0;
|
||
if (!delta) return;
|
||
e.preventDefault();
|
||
commit(0, values.value[0] + delta);
|
||
}
|
||
</script>
|
||
|
||
<style src="./Slider.css"></style>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/Slider.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 272 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/Slider.uniapp.vue</span><button class="m-copy" type="button" data-copy="code-216">复制</button></div>
|
||
<pre id="code-216"><code><template>
|
||
<view
|
||
class="kole-m-slider"
|
||
:class="sliderClass"
|
||
@touchstart="onTouchStart"
|
||
@touchmove="onTouchMove"
|
||
@touchend="onTouchEnd"
|
||
@touchcancel="onTouchEnd"
|
||
>
|
||
<view class="kole-m-slider__rail" ref="rail">
|
||
<view class="kole-m-slider__track">
|
||
<view class="kole-m-slider__filled" :style="filledStyle"></view>
|
||
<template v-if="marks">
|
||
<view
|
||
v-for="m in markPoints"
|
||
:key="'m' + m"
|
||
class="kole-m-slider__mark"
|
||
:style="{ left: m + '%' }"
|
||
></view>
|
||
</template>
|
||
<view
|
||
v-for="(v, i) in values"
|
||
:key="'t' + i"
|
||
class="kole-m-slider__thumb"
|
||
:style="{ left: pct(v) }"
|
||
:role="disabled ? '' : 'slider'"
|
||
:aria-valuenow="v"
|
||
:aria-valuemin="min"
|
||
:aria-valuemax="max"
|
||
:aria-disabled="disabled ? 'true' : 'false'"
|
||
></view>
|
||
</view>
|
||
</view>
|
||
<text v-if="showValue" class="kole-m-slider__value">{{ range ? values[0] + '~' + values[1] : values[0] }}</text>
|
||
<slot></slot>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
/* uni-app 端 · 滑动选择器(移动端)— 规格 §35
|
||
跨端差异(关键的一条):
|
||
① **手势只能用 touch 事件**(@touchstart / @touchmove / @touchend)——
|
||
小程序与 App 端没有 PointerEvent,也没有 pointerdown/pointermove;
|
||
② 坐标从 e.touches[0].clientX 取(uni 的事件对象与 DOM 事件不同,没有统一的 e.clientX 字段,
|
||
且 touchend 时 touches 为空数组,需回落到 e.changedTouches[0]);
|
||
③ 轨道位置用 ref 上的 uni.createSelectorQuery() 度量(小程序没有 getBoundingClientRect 的同步语义),
|
||
度量结果缓存在 layout 里,触摸期间不重复查询;
|
||
④ 尺寸用 rpx:88rpx = 375pt 下的 44px 触控最小边长,滑块 40rpx = 20px。 */
|
||
import { computed, ref } from 'vue';
|
||
|
||
const props = defineProps({
|
||
value: { type: Number, default: 0 },
|
||
valueMax: { type: Number, default: 100 },
|
||
min: { type: Number, default: 0 },
|
||
max: { type: Number, default: 100 },
|
||
step: { type: Number, default: 1 },
|
||
mode: { type: String, default: 'single' },
|
||
marks: { type: Boolean, default: false },
|
||
showValue: { type: Boolean, default: true },
|
||
disabled: { type: Boolean, default: false },
|
||
label: { type: String, default: '滑动选择' }
|
||
});
|
||
const emit = defineEmits(['change']);
|
||
|
||
const rail = ref(null);
|
||
const dragging = ref(false);
|
||
const active = ref(-1);
|
||
/* null = 跟随 value 属性;拖动后本地记账(与 Collapse 同一约定) */
|
||
const inner = ref(null);
|
||
/* 轨道度量缓存(左边界 + 宽度,单位 px);touchend 后清空,下次按下重新度量 */
|
||
const layout = ref(null);
|
||
const markPoints = [0, 25, 50, 75, 100];
|
||
|
||
const range = computed(() => props.mode === 'range');
|
||
const values = computed(() => inner.value || (range.value ? [props.value, props.valueMax] : [props.value]));
|
||
const sliderClass = computed(() => [
|
||
range.value ? 'kole-m-slider--range' : '',
|
||
props.marks ? 'kole-m-slider--marks' : '',
|
||
dragging.value ? 'is-dragging' : '',
|
||
props.disabled ? 'is-disabled' : ''
|
||
].filter(Boolean));
|
||
const filledStyle = computed(() => {
|
||
if (!range.value) return { right: 100 - ((values.value[0] - props.min) / (props.max - props.min)) * 100 + '%' };
|
||
return {
|
||
left: ((values.value[0] - props.min) / (props.max - props.min)) * 100 + '%',
|
||
right: 100 - ((values.value[1] - props.min) / (props.max - props.min)) * 100 + '%'
|
||
};
|
||
});
|
||
|
||
function pct(v) {
|
||
if (props.max === props.min) return '0%';
|
||
return ((v - props.min) / (props.max - props.min)) * 100 + '%';
|
||
}
|
||
function clamp(v) {
|
||
const snapped = Math.round((v - props.min) / props.step) * props.step + props.min;
|
||
return Math.max(props.min, Math.min(props.max, snapped));
|
||
}
|
||
function nearest(v) {
|
||
if (!range.value) return 0;
|
||
return Math.abs(values.value[0] - v) <= Math.abs(values.value[1] - v) ? 0 : 1;
|
||
}
|
||
function commit(index, v) {
|
||
let next = clamp(v);
|
||
if (range.value) {
|
||
const other = values.value[1 - index];
|
||
next = index === 0 ? Math.min(next, other - props.step) : Math.max(next, other + props.step);
|
||
}
|
||
const out = range.value ? [values.value[0], values.value[1]] : [next];
|
||
out[index] = next;
|
||
inner.value = out;
|
||
emit('change', range.value ? out : out[0]);
|
||
}
|
||
|
||
/* 触摸坐标:uni 的事件对象用 touches / changedTouches,没有 DOM 的 clientX */
|
||
function clientXOf(e) {
|
||
var list = (e && e.touches && e.touches.length ? e.touches : null) || (e && e.changedTouches) || [];
|
||
return list.length ? list[0].clientX : null;
|
||
}
|
||
|
||
/* 轨道度量:小程序用 uni.createSelectorQuery 异步拿 left / width */
|
||
function measure() {
|
||
return new Promise(function (resolve) {
|
||
if (typeof uni === 'undefined' || !uni.createSelectorQuery) { resolve(layout.value); return; }
|
||
uni
|
||
.createSelectorQuery()
|
||
.select('.kole-m-slider__rail')
|
||
.boundingClientRect(function (rect) {
|
||
resolve(rect && rect.width ? { left: rect.left, width: rect.width } : null);
|
||
})
|
||
.exec();
|
||
});
|
||
}
|
||
|
||
function valueAt(clientX, box) {
|
||
if (clientX === null) return null;
|
||
if (!box || !box.width) return null;
|
||
return clamp(props.min + ((clientX - box.left) / box.width) * (props.max - props.min));
|
||
}
|
||
|
||
function onTouchStart(e) {
|
||
if (props.disabled) return;
|
||
const x = clientXOf(e);
|
||
if (x === null) return;
|
||
measure().then(function (box) {
|
||
if (!box) return;
|
||
layout.value = box;
|
||
const v = valueAt(x, box);
|
||
if (v === null) return;
|
||
active.value = nearest(v);
|
||
dragging.value = true;
|
||
commit(nearest(v), v);
|
||
});
|
||
}
|
||
|
||
function onTouchMove(e) {
|
||
if (props.disabled || active.value < 0) return;
|
||
const v = valueAt(clientXOf(e), layout.value);
|
||
if (v === null) return;
|
||
commit(active.value, v);
|
||
}
|
||
|
||
function onTouchEnd() {
|
||
if (active.value < 0) return;
|
||
active.value = -1;
|
||
dragging.value = false;
|
||
layout.value = null;
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
.kole-m-slider {
|
||
--kole-m-slider-thumb: 40rpx; /* 滑块视觉直径(20px @375pt) */
|
||
--kole-m-slider-thumb-active: 48rpx; /* 拖动中的滑块直径(24px) */
|
||
--kole-m-slider-track: 8rpx; /* 轨道可视高度(4px) */
|
||
--kole-m-touch-target: 88rpx;
|
||
--kole-m-font-size-label: 28rpx;
|
||
--kole-m-gutter: 32rpx;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding-left: var(--kole-m-gutter);
|
||
padding-right: var(--kole-m-gutter);
|
||
background-color: var(--kole-color-card-bg);
|
||
color: var(--kole-color-text-body);
|
||
font-size: 32rpx;
|
||
}
|
||
|
||
/* 轨道热区:可视 8rpx 的轨道垂直居中在这条 88rpx 高的带子里 */
|
||
.kole-m-slider__rail {
|
||
position: relative;
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: var(--kole-m-touch-target);
|
||
}
|
||
|
||
.kole-m-slider__track {
|
||
position: relative;
|
||
width: 100%;
|
||
height: var(--kole-m-slider-track);
|
||
border-radius: 4rpx;
|
||
background-color: var(--kole-color-border);
|
||
}
|
||
|
||
.kole-m-slider__filled {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
border-radius: 4rpx;
|
||
background-color: var(--kole-color-brand);
|
||
}
|
||
|
||
.kole-m-slider__thumb {
|
||
position: absolute;
|
||
top: 50%;
|
||
box-sizing: border-box;
|
||
width: var(--kole-m-slider-thumb);
|
||
height: var(--kole-m-slider-thumb);
|
||
margin-top: -20rpx;
|
||
margin-left: -20rpx;
|
||
border: 4rpx solid var(--kole-color-brand);
|
||
border-radius: 50%;
|
||
background-color: var(--kole-color-card-bg);
|
||
}
|
||
|
||
/* 状态 dragging:滑块放大 + 轨道加一层浅底(手指抓住的反馈) */
|
||
.kole-m-slider.is-dragging .kole-m-slider__thumb {
|
||
width: var(--kole-m-slider-thumb-active);
|
||
height: var(--kole-m-slider-thumb-active);
|
||
margin-top: -24rpx;
|
||
margin-left: -24rpx;
|
||
}
|
||
|
||
.kole-m-slider.is-dragging .kole-m-slider__rail { background-color: var(--kole-color-brand-bg); }
|
||
|
||
/* 变体 marks=with-marks:轨道上出现刻度点(默认不渲染 —— 刻度是信息不是装饰) */
|
||
.kole-m-slider__mark {
|
||
display: none;
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 4rpx;
|
||
height: 16rpx;
|
||
margin-top: -8rpx;
|
||
margin-left: -2rpx;
|
||
border-radius: 2rpx;
|
||
background-color: var(--kole-color-text-placeholder);
|
||
}
|
||
|
||
.kole-m-slider--marks .kole-m-slider__mark { display: block; }
|
||
|
||
.kole-m-slider__value {
|
||
flex-shrink: 0;
|
||
min-width: 96rpx;
|
||
padding-left: 24rpx;
|
||
text-align: right;
|
||
color: var(--kole-color-text-secondary);
|
||
font-size: var(--kole-m-font-size-label);
|
||
}
|
||
|
||
/* 状态 disabled:整条置灰且不响应 */
|
||
.kole-m-slider.is-disabled .kole-m-slider__thumb {
|
||
border-color: var(--kole-color-text-disabled);
|
||
background-color: var(--kole-color-disabled-bg);
|
||
}
|
||
|
||
.kole-m-slider.is-disabled .kole-m-slider__filled { background-color: var(--kole-color-text-disabled); }
|
||
|
||
.kole-m-slider.is-disabled .kole-m-slider__value { color: var(--kole-color-text-disabled); }
|
||
</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">断言 16 条 · 全部通过</span>
|
||
<span class="m-status">报告 2026-09-20 16:50:33</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-slider.json(点击展开原始 JSON)</summary>
|
||
<div class="m-code"><pre><code>{
|
||
"schemaVersion": 1,
|
||
"sourceKind": "authored-spec",
|
||
"provenance": "authored-in-repo",
|
||
"specFile": "spec/移动端规格.md",
|
||
"specSection": "35 · 滑动选择器 Slider",
|
||
"confidence": "high",
|
||
"slug": "mobile-slider",
|
||
"name": "滑动选择器 Slider",
|
||
"semanticTypeCandidates": [
|
||
"slider",
|
||
"range-slider",
|
||
"gesture-input"
|
||
],
|
||
"variantDimensions": [
|
||
{
|
||
"name": "mode",
|
||
"values": [
|
||
"single",
|
||
"range"
|
||
]
|
||
},
|
||
{
|
||
"name": "marks",
|
||
"values": [
|
||
"with-marks",
|
||
"none"
|
||
]
|
||
},
|
||
{
|
||
"name": "showValue",
|
||
"values": [
|
||
"true",
|
||
"false"
|
||
]
|
||
}
|
||
],
|
||
"representativeVariants": [
|
||
{
|
||
"mode": "single",
|
||
"marks": "none",
|
||
"showValue": "true",
|
||
"label": "默认(单滑块 + 右侧数值)"
|
||
},
|
||
{
|
||
"mode": "single",
|
||
"marks": "with-marks",
|
||
"showValue": "true",
|
||
"label": "带刻度(轨道上标出等分点)"
|
||
},
|
||
{
|
||
"mode": "range",
|
||
"marks": "none",
|
||
"showValue": "true",
|
||
"label": "双滑块区间(左值不越过右值)"
|
||
}
|
||
],
|
||
"anatomy": {
|
||
"slider": "根元素,一行里放进「轨道热区 + 数值」",
|
||
"rail": "轨道热区,44px 高的可聚焦区域,承载指针事件与锚点",
|
||
"track": "轨道本体(可视 4px),承载选中段与刻度的边界",
|
||
"filled": "选中段,从起点铺到滑块位置",
|
||
"thumb": "滑块,位置由当前值的百分比决定;拖动中放大作为「抓住了」的反馈",
|
||
"mark": "刻度点,仅 marks=with-marks 时出现(只标位置,不写文案)"
|
||
},
|
||
"structurePatterns": {
|
||
"mode": "single(单滑块)/ range(双滑块,左值不越过右值)",
|
||
"marks": "with-marks(轨道上标出等分点)/ none(不标刻度)",
|
||
"showValue": "true(右侧显示当前值)/ false(不显示数值)"
|
||
},
|
||
"usageHints": [
|
||
"在一个连续区间里用一次滑动选出数值(预算上限、价格区间、音量、亮度)",
|
||
"轨道可视高度只有 4px,但整条轨道所在的行必须是 44px 高的热区",
|
||
"按下任意位置就要跳到该处 —— 触屏上要求用户精确抓住 20px 的滑块是点不中的",
|
||
"取值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值、右值不越过左值",
|
||
"值变化立即触发 change 事件(连续回传),不做防抖;松手时才做业务提交"
|
||
],
|
||
"doNotInvent": [
|
||
"数值输入框与滑块的双向绑定(宿主自行组合 Input,本组件只回传值)",
|
||
"刻度上的文案标注与区间高亮(只标位置,不写文案)",
|
||
"拖动结束后的「撤销 / 重置」流程",
|
||
"纵向滑块与圆形表盘式选择器"
|
||
],
|
||
"unknowns": [
|
||
"双滑块的最小间距是否应当是 1 个 step(当前是),还是用户可配",
|
||
"刻度数量是否应由 step 推导(当前按 5 等分固定标点)",
|
||
"拖动中是否要把当前值放大显示在滑块上方(气泡提示)"
|
||
],
|
||
"interaction": [
|
||
"轨道热区高 44px,**按下任意位置即跳到该处**,随后横向拖动连续改值;不要求抓住滑块",
|
||
"手势是「按下 + 拖动」一次连续动作,不响应长按或双击;横向分量优先,纵向滚动不被吞掉(touch-action: pan-y)",
|
||
"取值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值、右值不越过左值",
|
||
"拖动期间滑块放大到 24px,松手后 150ms 回弹;减少动态偏好下瞬时切换",
|
||
"值变化立即触发 change 事件(连续回传),不做防抖;松手时才做业务提交"
|
||
],
|
||
"accessibility": [
|
||
"单滑块:轨道热区是 role=\"slider\" 且可聚焦,写 aria-valuemin / aria-valuemax / aria-valuenow / aria-label",
|
||
"双滑块:外层是 role=\"group\" + aria-label,每个滑块各自持有 aria-valuenow(两条独立数值)",
|
||
"禁用时写 aria-disabled=\"true\",读屏会播报不可用",
|
||
"键盘:聚焦后左右方向键按 step 步进(触屏之外的替代路径)"
|
||
],
|
||
"api": {
|
||
"source": "implementation",
|
||
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
|
||
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
|
||
"props": [
|
||
{
|
||
"name": "value",
|
||
"type": "number",
|
||
"default": "0",
|
||
"desc": "受控当前值(range 模式下是左值),与 aria-valuenow 同步写(规格 §35.5)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "valueMax",
|
||
"type": "number",
|
||
"default": "100",
|
||
"desc": "mode=range 时的右值(single 模式忽略)(规格 §35.3)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "min",
|
||
"type": "number",
|
||
"default": "0",
|
||
"desc": "区间下限,拖动与键盘步进都不会低于它(规格 §35.5)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "max",
|
||
"type": "number",
|
||
"default": "100",
|
||
"desc": "区间上限,拖动与键盘步进都不会高于它(规格 §35.5)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "step",
|
||
"type": "number",
|
||
"default": "1",
|
||
"desc": "取值吸附步长;也是双滑块的最小间距与键盘步进量(规格 §35.5)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "mode",
|
||
"type": "'single' | 'range'",
|
||
"default": "'single'",
|
||
"desc": "变体 mode:单滑块还是双滑块区间(规格 §35.3)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "marks",
|
||
"type": "boolean",
|
||
"default": "false",
|
||
"desc": "变体 marks:轨道上是否标出等分刻度点(规格 §35.3)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "showValue",
|
||
"type": "boolean",
|
||
"default": "true",
|
||
"desc": "变体 showValue:是否在轨道右侧显示当前值(规格 §35.3)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "disabled",
|
||
"type": "boolean",
|
||
"default": "false",
|
||
"desc": "状态 disabled:整条置灰且不响应,指针与键盘都不改值(规格 §35.4)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "label",
|
||
"type": "string",
|
||
"default": "'滑动选择'",
|
||
"desc": "无障碍名称,落到 role=\"slider\" 的 aria-label(规格 §35.6)",
|
||
"required": false
|
||
}
|
||
],
|
||
"events": [
|
||
{
|
||
"name": "change",
|
||
"params": "(value)",
|
||
"desc": "拖动或键盘步进改值时触发,连续回传当前值(range 模式回传 [左值, 右值])(规格 §35.5)"
|
||
}
|
||
],
|
||
"slots": [
|
||
{
|
||
"name": "default",
|
||
"desc": "数值之后的自定义内容(如单位文案,规格 §35.2 slider)"
|
||
}
|
||
]
|
||
},
|
||
"variantClasses": {
|
||
"mode": {
|
||
"single": [],
|
||
"range": [
|
||
".kole-m-slider--range"
|
||
]
|
||
},
|
||
"marks": {
|
||
"none": [],
|
||
"with-marks": [
|
||
".kole-m-slider--marks",
|
||
".kole-m-slider__mark"
|
||
]
|
||
},
|
||
"showValue": {
|
||
"true": [],
|
||
"false": []
|
||
}
|
||
},
|
||
"demos": [
|
||
{
|
||
"id": "basic",
|
||
"group": "01 组件类型",
|
||
"title": "基础用法",
|
||
"desc": "在轨道上按住拖动即改值;轨道可视 4px、热区 44px 高。",
|
||
"variant": "mode=single"
|
||
},
|
||
{
|
||
"id": "steps",
|
||
"group": "01 组件类型",
|
||
"title": "带刻度",
|
||
"desc": "marks=with-marks:轨道上标出等分点;取值仍按 step 吸附。",
|
||
"variant": "marks=with-marks"
|
||
},
|
||
{
|
||
"id": "range",
|
||
"group": "01 组件类型",
|
||
"title": "双滑块区间",
|
||
"desc": "mode=range:两个滑块各自独立拖动,左值不会越过右值。",
|
||
"variant": "mode=range"
|
||
},
|
||
{
|
||
"id": "dragging",
|
||
"group": "02 组件状态",
|
||
"title": "拖动中",
|
||
"desc": "滑块由 20px 放大到 24px,并给轨道加一层浅底作「抓住了」的反馈。",
|
||
"variant": "状态 dragging"
|
||
},
|
||
{
|
||
"id": "disabled",
|
||
"group": "02 组件状态",
|
||
"title": "禁用",
|
||
"desc": "整条置灰且不响应:读屏播报不可用,指针事件不再改值。",
|
||
"variant": "disabled=true"
|
||
}
|
||
],
|
||
"related": [
|
||
{
|
||
"slug": "mobile-stepper",
|
||
"why": "有界区间里加减小整数、且需要精确值时用 Stepper;数值连续、更看重「大致位置」时才用 Slider"
|
||
},
|
||
{
|
||
"slug": "mobile-input",
|
||
"why": "需要键盘输入精确数值(金额、编号)时用 Input + 数字键盘;Slider 只适合粗选"
|
||
},
|
||
{
|
||
"slug": "mobile-form",
|
||
"why": "滑块作为表单里的一个字段时,标签、必填星号与错误提示由 Form 的 item 提供"
|
||
}
|
||
]
|
||
}</code></pre></div>
|
||
</details>
|
||
</section>
|
||
|
||
<footer class="m-foot">
|
||
<p>生成物:本页由 <code>node tools/build-mobile.mjs</code> 从契约 <code>components/mobile-slider.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>
|