滑动选择器Slider
在一个连续区间里用一次滑动选出数值(预算上限、价格区间、音量、亮度)
数据录入 规格 35 · 滑动选择器 Slider 6 端实现 触摸优先
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">
<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/mobile-slider.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Slider.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
在轨道上按住拖动即改值;轨道可视 4px、热区 44px 高。
查看代码(演示页原文 · 17 行)
<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>marks=with-marks:轨道上标出等分点;取值仍按 step 吸附。
查看代码(演示页原文 · 20 行)
<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>mode=range:两个滑块各自独立拖动,左值不会越过右值。
查看代码(演示页原文 · 17 行)
<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>02 组件状态
滑块由 20px 放大到 24px,并给轨道加一层浅底作「抓住了」的反馈。
查看代码(演示页原文 · 16 行)
<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>整条置灰且不响应:读屏播报不可用,指针事件不再改值。
查看代码(演示页原文 · 16 行)
<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>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
value | number | 0 | 受控当前值(range 模式下是左值),与 aria-valuenow 同步写(规格 §35.5) | N |
valueMax | number | 100 | mode=range 时的右值(single 模式忽略)(规格 §35.3) | N |
min | number | 0 | 区间下限,拖动与键盘步进都不会低于它(规格 §35.5) | N |
max | number | 100 | 区间上限,拖动与键盘步进都不会高于它(规格 §35.5) | N |
step | number | 1 | 取值吸附步长;也是双滑块的最小间距与键盘步进量(规格 §35.5) | N |
mode | 'single' | 'range' | 'single' | 变体 mode:单滑块还是双滑块区间(规格 §35.3) | N |
marks | boolean | false | 变体 marks:轨道上是否标出等分刻度点(规格 §35.3) | N |
showValue | boolean | true | 变体 showValue:是否在轨道右侧显示当前值(规格 §35.3) | N |
disabled | boolean | false | 状态 disabled:整条置灰且不响应,指针与键盘都不改值(规格 §35.4) | N |
label | string | '滑动选择' | 无障碍名称,落到 role="slider" 的 aria-label(规格 §35.6) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
change | (value) | 拖动或键盘步进改值时触发,连续回传当前值(range 模式回传 [左值, 右值])(规格 §35.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 数值之后的自定义内容(如单位文案,规格 §35.2 slider) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-slider-thumb | 20px | 滑块视觉直径 |
--kole-m-slider-thumb-active | 24px | 拖动中的滑块直径 |
--kole-m-slider-track | 4px | 轨道可视高度 |
何时使用
- 在一个连续区间里用一次滑动选出数值(预算上限、价格区间、音量、亮度)
- 轨道可视高度只有 4px,但整条轨道所在的行必须是 44px 高的热区
- 按下任意位置就要跳到该处 —— 触屏上要求用户精确抓住 20px 的滑块是点不中的
- 取值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值、右值不越过左值
- 值变化立即触发 change 事件(连续回传),不做防抖;松手时才做业务提交
交互与触控
- 轨道热区高 44px,**按下任意位置即跳到该处**,随后横向拖动连续改值;不要求抓住滑块
- 手势是「按下 + 拖动」一次连续动作,不响应长按或双击;横向分量优先,纵向滚动不被吞掉(touch-action: pan-y)
- 取值按 step 吸附并夹在 [min, max];mode=range 时左值不越过右值、右值不越过左值
- 拖动期间滑块放大到 24px,松手后 150ms 回弹;减少动态偏好下瞬时切换
- 值变化立即触发 change 事件(连续回传),不做防抖;松手时才做业务提交
无障碍
- 单滑块:轨道热区是 role="slider" 且可聚焦,写 aria-valuemin / aria-valuemax / aria-valuenow / aria-label
- 双滑块:外层是 role="group" + aria-label,每个滑块各自持有 aria-valuenow(两条独立数值)
- 禁用时写 aria-disabled="true",读屏会播报不可用
- 键盘:聚焦后左右方向键按 step 步进(触屏之外的替代路径)
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 步进器Stepper | 有界区间里加减小整数、且需要精确值时用 Stepper;数值连续、更看重「大致位置」时才用 Slider |
| 输入框Input | 需要键盘输入精确数值(金额、编号)时用 Input + 数字键盘;Slider 只适合粗选 |
| 表单Form | 滑块作为表单里的一个字段时,标签、必填星号与错误提示由 Form 的 item 提供 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 数值输入框与滑块的双向绑定(宿主自行组合 Input,本组件只回传值) |
| 禁止发明 | 刻度上的文案标注与区间高亮(只标位置,不写文案) |
| 禁止发明 | 拖动结束后的「撤销 / 重置」流程 |
| 禁止发明 | 纵向滑块与圆形表盘式选择器 |
| 规格未定 | 双滑块的最小间距是否应当是 1 个 step(当前是),还是用户可配 |
| 规格未定 | 刻度数量是否应由 step 推导(当前按 5 等分固定标点) |
| 规格未定 | 拖动中是否要把当前值放大显示在滑块上方(气泡提示) |
结构(anatomy)
| 字段 | 说明 |
|---|---|
slider | 根元素,一行里放进「轨道热区 + 数值」 |
rail | 轨道热区,44px 高的可聚焦区域,承载指针事件与锚点 |
track | 轨道本体(可视 4px),承载选中段与刻度的边界 |
filled | 选中段,从起点铺到滑块位置 |
thumb | 滑块,位置由当前值的百分比决定;拖动中放大作为「抓住了」的反馈 |
mark | 刻度点,仅 marks=with-marks 时出现(只标位置,不写文案) |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
mode | single / range | single (由数据驱动,无专属类) range .kole-m-slider--range |
marks | with-marks / none | with-marks .kole-m-slider--marks .kole-m-slider__mark none (由数据驱动,无专属类) |
showValue | true / false | true (由数据驱动,无专属类) false (由数据驱动,无专属类) |
代表变体
| 变体 | 标签 |
|---|---|
mode=single · marks=none · showValue=true | 默认(单滑块 + 右侧数值) |
mode=single · marks=with-marks · showValue=true | 带刻度(轨道上标出等分点) |
mode=range · marks=none · showValue=true | 双滑块区间(左值不越过右值) |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Slider.css · 纯样式(CSS) · 135 行
/* 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;
}
frameworks-mobile/Slider.html · H5 原生(无框架) · 254 行
<!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>
frameworks-mobile/Slider.jsx · React · 145 行
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>
);
}
frameworks-mobile/Slider.vue2.vue · Vue 2 · 170 行
<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>
frameworks-mobile/Slider.vue3.vue · Vue 3 · 156 行
<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>
frameworks-mobile/Slider.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 272 行
<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>
测试与回归
断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。
断言 16 条 · 全部通过 报告 2026-09-20 16:50:33
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs # 全量 5 个组件
npm run verify:mobile-docs # 本页内容完整性 + API 与源码一致性
设计契约
components/mobile-slider.json(点击展开原始 JSON)
{
"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 提供"
}
]
}