移动端导航滑动选择器

滑动选择器Slider

在一个连续区间里用一次滑动选出数值(预算上限、价格区间、音量、亮度)

数据录入 规格 35 · 滑动选择器 Slider 6 端实现 触摸优先

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

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

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

演示

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

01 组件类型

基础用法mode=single

在轨道上按住拖动即改值;轨道可视 4px、热区 44px 高。

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

marks=with-marks:轨道上标出等分点;取值仍按 step 吸附。

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

mode=range:两个滑块各自独立拖动,左值不会越过右值。

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

拖动中状态 dragging

滑块由 20px 放大到 24px,并给轨道加一层浅底作「抓住了」的反馈。

查看代码(演示页原文 · 16 行)
frameworks-mobile/Slider.html · dragging
<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>
禁用disabled=true

整条置灰且不响应:读屏播报不可用,指针事件不再改值。

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

名称类型默认值说明必传
valuenumber0受控当前值(range 模式下是左值),与 aria-valuenow 同步写(规格 §35.5)N
valueMaxnumber100mode=range 时的右值(single 模式忽略)(规格 §35.3)N
minnumber0区间下限,拖动与键盘步进都不会低于它(规格 §35.5)N
maxnumber100区间上限,拖动与键盘步进都不会高于它(规格 §35.5)N
stepnumber1取值吸附步长;也是双滑块的最小间距与键盘步进量(规格 §35.5)N
mode'single' | 'range''single'变体 mode:单滑块还是双滑块区间(规格 §35.3)N
marksbooleanfalse变体 marks:轨道上是否标出等分刻度点(规格 §35.3)N
showValuebooleantrue变体 showValue:是否在轨道右侧显示当前值(规格 §35.3)N
disabledbooleanfalse状态 disabled:整条置灰且不响应,指针与键盘都不改值(规格 §35.4)N
labelstring'滑动选择'无障碍名称,落到 role="slider" 的 aria-label(规格 §35.6)N

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

事件

名称参数说明
change(value)拖动或键盘步进改值时触发,连续回传当前值(range 模式回传 [左值, 右值])(规格 §35.5)

插槽

名称说明
default数值之后的自定义内容(如单位文案,规格 §35.2 slider)

CSS 变量

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

名称默认值说明
--kole-m-slider-thumb20px滑块视觉直径
--kole-m-slider-thumb-active24px拖动中的滑块直径
--kole-m-slider-track4px轨道可视高度

何时使用

  • 在一个连续区间里用一次滑动选出数值(预算上限、价格区间、音量、亮度)
  • 轨道可视高度只有 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 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
modesingle / range
single (由数据驱动,无专属类)
range .kole-m-slider--range
markswith-marks / none
with-marks .kole-m-slider--marks .kole-m-slider__mark
none (由数据驱动,无专属类)
showValuetrue / false
true (由数据驱动,无专属类)
false (由数据驱动,无专属类)

代表变体

变体标签
mode=single · marks=none · showValue=true默认(单滑块 + 右侧数值)
mode=single · marks=with-marks · showValue=true带刻度(轨道上标出等分点)
mode=range · marks=none · showValue=true双滑块区间(左值不越过右值)

用到的令牌

构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。

--kole-m-font-size-body --kole-m-font-size-label --kole-m-gutter --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-bg --kole-color-card-bg --kole-color-disabled-bg --kole-color-focus-ring --kole-color-text-body --kole-color-text-disabled --kole-color-text-placeholder --kole-color-text-secondary --kole-ease-standard --kole-shadow-low --kole-space-12 --kole-m-slider-thumb --kole-m-slider-thumb-active --kole-m-slider-track

6 端源码

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

frameworks-mobile/Slider.css · 纯样式(CSS) · 135 行
frameworks-mobile/Slider.css
/* 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 行
frameworks-mobile/Slider.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · 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 行
frameworks-mobile/Slider.jsx
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 行
frameworks-mobile/Slider.vue2.vue
<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 行
frameworks-mobile/Slider.vue3.vue
<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 行
frameworks-mobile/Slider.uniapp.vue
<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 提供"
    }
  ]
}