移动端导航步进器

步进器Stepper

在一段有界区间里连续加减小整数(购买数量、份数、编号)

数据录入 规格 30 · 步进器 Stepper 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-stepper.css">

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

演示

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

01 组件类型

基础用法size=default

减号 / 值 / 加号三段;加减各自是 44px 见方热区。

查看代码(演示页原文 · 14 行)
frameworks-mobile/Stepper.html · basic
<section class="demo-block" data-demo="basic">
  <p class="demo-label">基础用法(减号 / 值 / 加号三段;加减各自是 44px 见方热区)</p>
  <div class="demo-box">
    <div class="demo-row">
      <span class="demo-name">购买数量</span>
      <div class="kole-m-stepper" role="group" aria-label="购买数量" id="st-basic" data-value="2" data-assert="stepper-basic">
        <button class="kole-m-stepper__minus" type="button" aria-label="减少"
                data-behavior="click-sets-attr:#st-basic|data-value|1">−</button>
        <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="2" aria-valuemin="1" aria-valuemax="99">2</span>
        <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
      </div>
    </div>
  </div>
</section>
圆角两态round=false|true

round=true 全圆角用于购物车;round=false 方角嵌在表单里。

查看代码(演示页原文 · 21 行)
frameworks-mobile/Stepper.html · size
<section class="demo-block" data-demo="size">
  <p class="demo-label">圆角两态(round=true 全圆角用于购物车 / round=false 方角嵌在表单里)</p>
  <div class="demo-box" data-assert="stepper-round">
    <div class="demo-row">
      <span class="demo-name">round=true</span>
      <div class="kole-m-stepper kole-m-stepper--round" role="group" aria-label="全圆角">
        <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
        <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="3" aria-valuemin="1" aria-valuemax="20">3</span>
        <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
      </div>
    </div>
    <div class="demo-row" style="margin-top: var(--kole-space-16)">
      <span class="demo-name">round=false</span>
      <div class="kole-m-stepper" role="group" aria-label="方角">
        <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
        <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="3" aria-valuemin="1" aria-valuemax="20">3</span>
        <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
      </div>
    </div>
  </div>
</section>
紧凑尺寸size=small

size=small 视觉 36px,热区用 --kole-m-hit-slack 外扩回 44px。

查看代码(演示页原文 · 14 行)
frameworks-mobile/Stepper.html · size-small
<section class="demo-block" data-demo="size-small">
  <p class="demo-label">紧凑尺寸(size=small 视觉 36px,热区用 hit-slack 外扩回 44px)</p>
  <div class="demo-box" data-assert="stepper-small">
    <div class="demo-row">
      <span class="demo-name">小尺寸</span>
      <div class="kole-m-stepper kole-m-stepper--small" role="group" aria-label="紧凑数量">
        <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
        <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="1" aria-valuemin="1" aria-valuemax="99">1</span>
        <span class="kole-m-stepper__unit" aria-hidden="true">件</span>
        <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
      </div>
    </div>
  </div>
</section>

02 组件状态

边界状态 min|max

到 min / max 时按钮留在原位置灰而不隐藏:按钮消失会让用户以为界面坏了。

查看代码(演示页原文 · 23 行)
frameworks-mobile/Stepper.html · bounds
<section class="demo-block" data-demo="bounds">
  <p class="demo-label">边界(到 min / max 时按钮**留在原位置灰**,不隐藏:按钮消失会让人以为坏了)</p>
  <div class="demo-box" data-assert="stepper-bounds">
    <div class="demo-row">
      <span class="demo-name">已到最小值(min=1)</span>
      <div class="kole-m-stepper" role="group" aria-label="已到最小值" id="st-min"
           data-min="1" data-max="99" data-value="1">
        <button class="kole-m-stepper__minus" type="button" aria-label="减少" disabled>−</button>
        <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="1" aria-valuemin="1" aria-valuemax="99">1</span>
        <button class="kole-m-stepper__plus" type="button" aria-label="增加"
                data-behavior="click-sets-attr:#st-min|data-value|2">+</button>
      </div>
    </div>
    <div class="demo-row" style="margin-top: var(--kole-space-16)">
      <span class="demo-name">已到最大值(max=9)</span>
      <div class="kole-m-stepper" role="group" aria-label="已到最大值" data-min="1" data-max="9" data-value="9">
        <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
        <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="9" aria-valuemin="1" aria-valuemax="9">9</span>
        <button class="kole-m-stepper__plus" type="button" aria-label="增加" disabled>+</button>
      </div>
    </div>
  </div>
</section>
禁用disabled=true

整组置灰、两个按钮都不可聚焦;读屏会播报不可用。

查看代码(演示页原文 · 13 行)
frameworks-mobile/Stepper.html · disabled
<section class="demo-block" data-demo="disabled">
  <p class="demo-label">禁用(整组置灰,两个按钮都不可聚焦;读屏会播报不可用)</p>
  <div class="demo-box">
    <div class="demo-row" data-assert="stepper-disabled">
      <span class="demo-name">库存不足</span>
      <div class="kole-m-stepper is-disabled" role="group" aria-label="库存不足">
        <button class="kole-m-stepper__minus" type="button" aria-label="减少" disabled>−</button>
        <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="1" aria-valuemin="1" aria-valuemax="99">1</span>
        <button class="kole-m-stepper__plus" type="button" aria-label="增加" disabled>+</button>
      </div>
    </div>
  </div>
</section>

API

props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs

Props

名称类型默认值说明必传
valuenumber1当前值,受控;变化通过 change 回传(规格 §30.5)N
minnumber1下界,到达时减号置灰(规格 §30.4)N
maxnumber99上界,到达时加号置灰(规格 §30.4)N
stepnumber1单次步进量(规格 §30.5)N
size'default' | 'small''default'变体 size:default 高 44px,small 视觉 36px(热区外扩回 44px)(规格 §30.3)N
roundbooleanfalse变体 round:全圆角(规格 §30.3)N
editablebooleanfalse值区是否可键盘直接输入(规格 §30.5)N
disabledbooleanfalse状态 disabled:整组置灰且不可聚焦(规格 §30.4)N
labelstring''无障碍分组名称,落到 role="group" 的 aria-label(规格 §30.6)N

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

事件

名称参数说明
change(value)点加号 / 减号后触发,回传夹到 min~max 的目标值(规格 §30.5)
input(value)editable=true 时值区输入触发,回传原始文本(规格 §30.5)

插槽

名称说明
default值后面的单位文案(如「件」),渲染在加减号之间(规格 §30.2 unit)

CSS 变量

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

名称默认值说明
--kole-m-stepper-heightvar(--kole-m-touch-target)控件高(≥ 44px 触控最小边长)
--kole-m-stepper-btnvar(--kole-m-touch-target)单个加减按钮边长(44px 见方)
--kole-m-stepper-radiusvar(--kole-radius-base)round=false 时的圆角
--kole-m-stepper-height36px组件内部默认值,可在业务侧覆盖
--kole-m-stepper-btn36px组件内部默认值,可在业务侧覆盖

何时使用

  • 在一段有界区间里连续加减小整数(购买数量、份数、编号)
  • 加号与减号必须各自独立占一个 44px 见方的热区
  • 到边界时不是把按钮藏起来,而是置灰 —— 触屏上「按钮消失」会让用户以为界面坏了
  • 单击步进一个 step,不响应长按连击(长按是桌面习惯,触屏上容易多跳)
  • 值变化后立即触发 change 事件,不做防抖

交互与触控

  • 加号与减号各自是 44px 见方的热区;size=small 视觉 36px 时用 --kole-m-hit-slack 把热区外扩回 44px
  • 单击步进一个 step,不响应长按连击(长按是桌面习惯,触屏上容易多跳)
  • 到达 min / max 时对应按钮置 disabled 且置灰,点击不产生值与事件
  • 值变化后立即触发 change 事件,不做防抖
  • editable=true 时值区接受键盘直接输入;非法输入(非数字、越界)在失焦时回落到边界值

无障碍

  • 根元素 role="group" + aria-label 说明这组控件在调什么
  • 减号 / 加号是原生 button,各自的 aria-label 是「减少」/「增加」(不用符号代替名称)
  • 值区在只读态置 role="spinbutton" 并写 aria-valuenow / aria-valuemin / aria-valuemax
  • 置灰的按钮用原生 disabled,读屏会播报不可用且键盘会跳过

从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。

组件何时用它而不是本组件
输入框Input输入范围不固定、也不是整数时用输入框;有界的整数加减用步进器
数字键盘NumberKeyboard数量大到要手打时,输入框配套数字键盘;少量加减直接让步进器承担
单元格Cell步进器常作为单元格的右侧内容;行结构与点击进下级由单元格负责

规格未定 / 禁止发明

类别条目
禁止发明长按连击、惯性加速的时值曲线
禁止发明小数与浮点精度(本组件只处理整数;金额请用输入框 + 数字键盘)
禁止发明超出边界的提示文案(由宿主决定是否提示)
规格未定值为 0 时是否自动隐藏整个步进器(购物车场景)
规格未定是否需要键盘上的上下方向键加减
规格未定单位文案是否随语言变化

结构(anatomy)

字段说明
stepper根元素,一行里放进「减号 + 值 + 加号」
minus减号按钮,到达 min 时置灰
value值区;editable=true 时是可聚焦的数字输入框,否则是纯文本
plus加号按钮,到达 max 时置灰
unit可选单位文案,跟在值后面(如「件」),由默认插槽给出
label无障碍分组名称(role="group" + aria-label)

变体维度与类名映射

类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
sizedefault / small
default (由数据驱动,无专属类)
small .kole-m-stepper--small
roundfalse / true
false (由数据驱动,无专属类)
true .kole-m-stepper--round

代表变体

变体标签
size=default · round=false默认(控件高 44px,方角嵌在表单里)
size=default · round=true全圆角(购物车等轻量场合)
size=small · round=false紧凑(视觉 36px,热区外扩回 44px)

用到的令牌

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

--kole-m-font-size-body --kole-m-font-size-label --kole-m-font-size-title --kole-m-hit-slack --kole-m-touch-target --kole-color-border --kole-color-brand-bg --kole-color-card-bg --kole-color-disabled-bg --kole-color-focus-ring --kole-color-table-header-bg --kole-color-text-body --kole-color-text-disabled --kole-color-text-secondary --kole-radius-base --kole-space-12 --kole-space-8 --kole-m-stepper-btn --kole-m-stepper-height --kole-m-stepper-radius

6 端源码

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

frameworks-mobile/Stepper.css · 纯样式(CSS) · 141 行
frameworks-mobile/Stepper.css
/* Kole UI Mobile · Stepper 样式 — 对齐移动端规格 §30
   步进器:减号 / 值 / 加号三段等分,控件高 44px(触控最小边长);
   到边界时按钮**留在原位置灰**(不隐藏)—— 触屏上按钮消失会让用户以为界面坏了;
   size=small 视觉 36px,用 --kole-m-hit-slack 把热区外扩回 44px(令牌的既定用途)。 */

.kole-m-stepper {
  /* 组件级变量:业务侧可在容器上覆盖 */
  --kole-m-stepper-height: var(--kole-m-touch-target);  /* 控件高(≥ 44px 触控最小边长) */
  --kole-m-stepper-btn: var(--kole-m-touch-target);      /* 单个加减按钮边长(44px 见方) */
  --kole-m-stepper-radius: var(--kole-radius-base);      /* round=false 时的圆角 */
  box-sizing: border-box;
  display: inline-flex;
  align-items: stretch;
  min-height: var(--kole-m-stepper-height);
  border: 1px solid var(--kole-color-border);
  border-radius: var(--kole-m-stepper-radius);
  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;
  /* 不用 overflow: hidden 裁圆角 —— 那会把 size=small 的热区外扩(::after)一起裁掉。
     圆角改为分别画在两个按钮的内侧角上,见下面的 border-radius。 */
}

/* 变体 round=true:全圆角(购物车一类轻量场合) */
.kole-m-stepper--round { --kole-m-stepper-radius: 999px; }

/* 变体 size=small:视觉 36px 紧凑;热区用 hit-slack 外扩回 44px */
.kole-m-stepper--small {
  --kole-m-stepper-height: 36px;
  --kole-m-stepper-btn: 36px;
}

.kole-m-stepper--small .kole-m-stepper__minus,
.kole-m-stepper--small .kole-m-stepper__plus {
  position: relative;
}

.kole-m-stepper--small .kole-m-stepper__minus::after,
.kole-m-stepper--small .kole-m-stepper__plus::after {
  content: '';
  position: absolute;
  inset: calc(var(--kole-m-hit-slack) * -1);
}

.kole-m-stepper__minus,
.kole-m-stepper__plus {
  flex: 0 0 auto;
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--kole-m-stepper-btn);
  min-height: var(--kole-m-stepper-height);
  padding: 0;
  border: 0;
  background: var(--kole-color-table-header-bg);
  color: var(--kole-color-text-body);
  font-family: inherit;
  font-size: var(--kole-m-font-size-title);
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
}

.kole-m-stepper__minus:active:not(:disabled),
.kole-m-stepper__plus:active:not(:disabled) { background: var(--kole-color-brand-bg); }

.kole-m-stepper__minus { border-right: 1px solid var(--kole-color-border); }
.kole-m-stepper__plus { border-left: 1px solid var(--kole-color-border); }

/* 圆角画在两端按钮的外侧角上(替代根元素的 overflow: hidden,那样会裁掉热区外扩) */
.kole-m-stepper__minus {
  border-top-left-radius: calc(var(--kole-m-stepper-radius) - 1px);
  border-bottom-left-radius: calc(var(--kole-m-stepper-radius) - 1px);
}

.kole-m-stepper__plus {
  border-top-right-radius: calc(var(--kole-m-stepper-radius) - 1px);
  border-bottom-right-radius: calc(var(--kole-m-stepper-radius) - 1px);
}

/* 状态:到边界(或整组 disabled)→ 按钮原位置灰,不隐藏 */
.kole-m-stepper__minus:disabled,
.kole-m-stepper__plus:disabled {
  color: var(--kole-color-text-disabled);
  background: var(--kole-color-disabled-bg);
  cursor: not-allowed;
}

/* 值区:只读态是文本(role="spinbutton"),editable 时是数字输入框 */
.kole-m-stepper__value {
  flex: 1 1 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-width: 44px;
  min-height: var(--kole-m-stepper-height);
  padding: 0 var(--kole-space-8);
  color: var(--kole-color-text-body);
  /* 数字用等宽数字:加减时宽度不跳,视觉不抖 */
  font-variant-numeric: tabular-nums;
  text-align: center;
}

input.kole-m-stepper__value {
  width: 100%;
  border: 0;
  outline: none;
  background: transparent;
  font-family: inherit;
  font-size: inherit;
}

input.kole-m-stepper__value:focus-within { background: var(--kole-color-brand-bg); }

.kole-m-stepper__unit {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  padding-right: var(--kole-space-12);
  color: var(--kole-color-text-secondary);
  font-size: var(--kole-m-font-size-label);
}

/* 状态 disabled:整组置灰 */
.kole-m-stepper.is-disabled {
  background: var(--kole-color-disabled-bg);
  border-color: var(--kole-color-border);
}

.kole-m-stepper.is-disabled .kole-m-stepper__value { color: var(--kole-color-text-disabled); }

.kole-m-stepper__minus:focus-visible,
.kole-m-stepper__plus:focus-visible {
  outline: 2px solid var(--kole-color-focus-ring);
  outline-offset: -2px;
}
frameworks-mobile/Stepper.html · H5 原生(无框架) · 171 行
frameworks-mobile/Stepper.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 · Stepper(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Stepper.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-box { padding: var(--kole-m-gutter); background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
  .demo-row { display: flex; align-items: center; justify-content: space-between; gap: var(--kole-space-12); }
  .demo-name { font-size: var(--kole-m-font-size-body); color: var(--kole-color-text-title); }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="basic">
    <p class="demo-label">基础用法(减号 / 值 / 加号三段;加减各自是 44px 见方热区)</p>
    <div class="demo-box">
      <div class="demo-row">
        <span class="demo-name">购买数量</span>
        <div class="kole-m-stepper" role="group" aria-label="购买数量" id="st-basic" data-value="2" data-assert="stepper-basic">
          <button class="kole-m-stepper__minus" type="button" aria-label="减少"
                  data-behavior="click-sets-attr:#st-basic|data-value|1">−</button>
          <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="2" aria-valuemin="1" aria-valuemax="99">2</span>
          <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="bounds">
    <p class="demo-label">边界(到 min / max 时按钮**留在原位置灰**,不隐藏:按钮消失会让人以为坏了)</p>
    <div class="demo-box" data-assert="stepper-bounds">
      <div class="demo-row">
        <span class="demo-name">已到最小值(min=1)</span>
        <div class="kole-m-stepper" role="group" aria-label="已到最小值" id="st-min"
             data-min="1" data-max="99" data-value="1">
          <button class="kole-m-stepper__minus" type="button" aria-label="减少" disabled>−</button>
          <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="1" aria-valuemin="1" aria-valuemax="99">1</span>
          <button class="kole-m-stepper__plus" type="button" aria-label="增加"
                  data-behavior="click-sets-attr:#st-min|data-value|2">+</button>
        </div>
      </div>
      <div class="demo-row" style="margin-top: var(--kole-space-16)">
        <span class="demo-name">已到最大值(max=9)</span>
        <div class="kole-m-stepper" role="group" aria-label="已到最大值" data-min="1" data-max="9" data-value="9">
          <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
          <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="9" aria-valuemin="1" aria-valuemax="9">9</span>
          <button class="kole-m-stepper__plus" type="button" aria-label="增加" disabled>+</button>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="size">
    <p class="demo-label">圆角两态(round=true 全圆角用于购物车 / round=false 方角嵌在表单里)</p>
    <div class="demo-box" data-assert="stepper-round">
      <div class="demo-row">
        <span class="demo-name">round=true</span>
        <div class="kole-m-stepper kole-m-stepper--round" role="group" aria-label="全圆角">
          <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
          <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="3" aria-valuemin="1" aria-valuemax="20">3</span>
          <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
        </div>
      </div>
      <div class="demo-row" style="margin-top: var(--kole-space-16)">
        <span class="demo-name">round=false</span>
        <div class="kole-m-stepper" role="group" aria-label="方角">
          <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
          <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="3" aria-valuemin="1" aria-valuemax="20">3</span>
          <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="size-small">
    <p class="demo-label">紧凑尺寸(size=small 视觉 36px,热区用 hit-slack 外扩回 44px)</p>
    <div class="demo-box" data-assert="stepper-small">
      <div class="demo-row">
        <span class="demo-name">小尺寸</span>
        <div class="kole-m-stepper kole-m-stepper--small" role="group" aria-label="紧凑数量">
          <button class="kole-m-stepper__minus" type="button" aria-label="减少">−</button>
          <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="1" aria-valuemin="1" aria-valuemax="99">1</span>
          <span class="kole-m-stepper__unit" aria-hidden="true">件</span>
          <button class="kole-m-stepper__plus" type="button" aria-label="增加">+</button>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="disabled">
    <p class="demo-label">禁用(整组置灰,两个按钮都不可聚焦;读屏会播报不可用)</p>
    <div class="demo-box">
      <div class="demo-row" data-assert="stepper-disabled">
        <span class="demo-name">库存不足</span>
        <div class="kole-m-stepper is-disabled" role="group" aria-label="库存不足">
          <button class="kole-m-stepper__minus" type="button" aria-label="减少" disabled>−</button>
          <span class="kole-m-stepper__value" role="spinbutton" aria-valuenow="1" aria-valuemin="1" aria-valuemax="99">1</span>
          <button class="kole-m-stepper__plus" type="button" aria-label="增加" disabled>+</button>
        </div>
      </div>
    </div>
  </section>
</div>
<script>
  /* 演示页脚本:真实的加一 / 减一。
     - 点加号 / 减号:值变化,同时更新 aria-valuenow
     - 到边界:对应按钮置原生 disabled(留在原位,不隐藏)
     阈值写在 data-min / data-max 上(默认 1 / 99),真实业务里这份状态由宿主管理。 */
  (function () {
    Array.prototype.forEach.call(document.querySelectorAll('.kole-m-stepper'), function (root) {
      var minus = root.querySelector('.kole-m-stepper__minus');
      var plus = root.querySelector('.kole-m-stepper__plus');
      var valueEl = root.querySelector('.kole-m-stepper__value');
      if (!minus || !plus || !valueEl || root.classList.contains('is-disabled')) return;

      var min = Number(root.getAttribute('data-min') || 1);
      var max = Number(root.getAttribute('data-max') || 99);

      function readValue() {
        var fromAttr = root.getAttribute('data-value');
        if (fromAttr !== null && fromAttr !== '') return Number(fromAttr);
        return Number(String(valueEl.textContent || '').replace(/[^0-9]/g, '')) || min;
      }

      function render(v) {
        var next = Math.max(min, Math.min(max, v));
        valueEl.textContent = String(next);
        valueEl.setAttribute('aria-valuenow', String(next));
        root.setAttribute('data-value', String(next));
        minus.disabled = next <= min;
        plus.disabled = next >= max;
      }

      minus.addEventListener('click', function () { render(readValue() - 1); });
      plus.addEventListener('click', function () { render(readValue() + 1); });

      render(readValue());
    });
  })();
</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/Stepper.jsx · React · 87 行
frameworks-mobile/Stepper.jsx
import React from 'react';
import './Stepper.css';

/* 步进器(移动端)— 规格 §30
   减号 / 值 / 加号三段,加减各自是 44px 见方热区(规格 §30.5)。
   到 min / max 时按钮**留在原位灰**(不隐藏):触屏上按钮消失会让用户以为界面坏了。
   值区在只读态是 role="spinbutton" + aria-valuenow/min/max;editable=true 时是可聚焦的数字输入框。
   本端只回传目标值(onChange),是否采纳由宿主决定;不做长按连击(桌面习惯,触屏易多跳)。 */

export default function Stepper({
  value = 1,
  min = 1,
  max = 99,
  step = 1,
  size = 'default',
  round = false,
  editable = false,
  disabled = false,
  label = '',
  onChange,
  onInput,
  children = null,
}) {
  const atMin = value <= min;
  const atMax = value >= max;

  const cls =
    'kole-m-stepper' +
    (size === 'small' ? ' kole-m-stepper--small' : '') +
    (round ? ' kole-m-stepper--round' : '') +
    (disabled ? ' is-disabled' : '');

  function emit(next) {
    const clamped = Math.max(min, Math.min(max, next));
    if (clamped === value) return;
    if (onChange) onChange(clamped);
  }

  return (
    <div className={cls} role="group" aria-label={label || undefined}>
      <button
        className="kole-m-stepper__minus"
        type="button"
        aria-label="减少"
        disabled={disabled || atMin}
        onClick={() => emit(value - step)}
      >
        −
      </button>
      {editable ? (
        <input
          className="kole-m-stepper__value"
          type="text"
          inputMode="numeric"
          value={String(value)}
          disabled={disabled}
          aria-label={label || '数量'}
          onChange={(e) => {
            if (disabled) return;
            if (onInput) onInput(e.target.value);
          }}
        />
      ) : (
        <span
          className="kole-m-stepper__value"
          role="spinbutton"
          aria-valuenow={value}
          aria-valuemin={min}
          aria-valuemax={max}
        >
          {value}
        </span>
      )}
      {children}
      <button
        className="kole-m-stepper__plus"
        type="button"
        aria-label="增加"
        disabled={disabled || atMax}
        onClick={() => emit(value + step)}
      >
        +
      </button>
    </div>
  );
}
frameworks-mobile/Stepper.vue2.vue · Vue 2 · 85 行
frameworks-mobile/Stepper.vue2.vue
<template>
  <div class="kole-m-stepper" :class="stepperClass" role="group" :aria-label="label || null">
    <button
      class="kole-m-stepper__minus"
      type="button"
      aria-label="减少"
      :disabled="disabled || atMin"
      @click="$emit('change', value - step)"
    >
      −
    </button>
    <input
      v-if="editable"
      class="kole-m-stepper__value"
      type="text"
      inputmode="numeric"
      :value="String(value)"
      :disabled="disabled"
      :aria-label="label || '数量'"
      @input="onInput"
    />
    <span
      v-else
      class="kole-m-stepper__value"
      role="spinbutton"
      :aria-valuenow="value"
      :aria-valuemin="min"
      :aria-valuemax="max"
      >{{ value }}</span
    >
    <slot></slot>
    <button
      class="kole-m-stepper__plus"
      type="button"
      aria-label="增加"
      :disabled="disabled || atMax"
      @click="$emit('change', value + step)"
    >
      +
    </button>
  </div>
</template>

<script>
/* 步进器(移动端)— 规格 §30
   减号 / 值 / 加号三段,加减各自是 44px 见方热区(规格 §30.5)。
   到 min / max 时按钮**留在原位灰**(不隐藏):触屏上按钮消失会让用户以为界面坏了。
   值区在只读态是 role="spinbutton" + aria-valuenow/min/max;editable=true 时是可聚焦的数字输入框。
   本端只回传目标值(change),是否采纳由宿主决定;不做长按连击(桌面习惯,触屏易多跳)。 */

export default {
  name: 'KoleMStepper',
  props: {
    value: { type: Number, default: 1 },
    min: { type: Number, default: 1 },
    max: { type: Number, default: 99 },
    step: { type: Number, default: 1 },
    size: { type: String, default: 'default' },
    round: { type: Boolean, default: false },
    editable: { type: Boolean, default: false },
    disabled: { type: Boolean, default: false },
    label: { type: String, default: '' }
  },
  computed: {
    atMin: function () { return this.value <= this.min; },
    atMax: function () { return this.value >= this.max; },
    stepperClass: function () {
      return [
        this.size === 'small' ? 'kole-m-stepper--small' : '',
        this.round ? 'kole-m-stepper--round' : '',
        this.disabled ? 'is-disabled' : ''
      ].filter(Boolean);
    }
  },
  methods: {
    onInput: function (e) {
      if (this.disabled) return;
      this.$emit('input', e.target.value);
    }
  }
};
</script>

<style src="./Stepper.css"></style>
frameworks-mobile/Stepper.vue3.vue · Vue 3 · 81 行
frameworks-mobile/Stepper.vue3.vue
<template>
  <div class="kole-m-stepper" :class="stepperClass" role="group" :aria-label="label || null">
    <button
      class="kole-m-stepper__minus"
      type="button"
      aria-label="减少"
      :disabled="disabled || atMin"
      @click="emit('change', value - step)"
    >
      −
    </button>
    <input
      v-if="editable"
      class="kole-m-stepper__value"
      type="text"
      inputmode="numeric"
      :value="String(value)"
      :disabled="disabled"
      :aria-label="label || '数量'"
      @input="onInput"
    />
    <span
      v-else
      class="kole-m-stepper__value"
      role="spinbutton"
      :aria-valuenow="value"
      :aria-valuemin="min"
      :aria-valuemax="max"
      >{{ value }}</span
    >
    <slot></slot>
    <button
      class="kole-m-stepper__plus"
      type="button"
      aria-label="增加"
      :disabled="disabled || atMax"
      @click="emit('change', value + step)"
    >
      +
    </button>
  </div>
</template>

<script setup>
/* 步进器(移动端)— 规格 §30
   减号 / 值 / 加号三段,加减各自是 44px 见方热区(规格 §30.5)。
   到 min / max 时按钮**留在原位灰**(不隐藏):触屏上按钮消失会让用户以为界面坏了。
   值区在只读态是 role="spinbutton" + aria-valuenow/min/max;editable=true 时是可聚焦的数字输入框。
   本端只回传目标值(change),是否采纳由宿主决定;不做长按连击(桌面习惯,触屏易多跳)。 */
import { computed } from 'vue';

const props = defineProps({
  value: { type: Number, default: 1 },
  min: { type: Number, default: 1 },
  max: { type: Number, default: 99 },
  step: { type: Number, default: 1 },
  size: { type: String, default: 'default' },
  round: { type: Boolean, default: false },
  editable: { type: Boolean, default: false },
  disabled: { type: Boolean, default: false },
  label: { type: String, default: '' }
});
const emit = defineEmits(['change', 'input']);

const atMin = computed(() => props.value <= props.min);
const atMax = computed(() => props.value >= props.max);

const stepperClass = computed(() => [
  props.size === 'small' ? 'kole-m-stepper--small' : '',
  props.round ? 'kole-m-stepper--round' : '',
  props.disabled ? 'is-disabled' : ''
].filter(Boolean));

function onInput(e) {
  if (props.disabled) return;
  emit('input', e.target.value);
}
</script>

<style src="./Stepper.css"></style>
frameworks-mobile/Stepper.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 178 行
frameworks-mobile/Stepper.uniapp.vue
<template>
  <view class="kole-m-stepper" :class="stepperClass" role="group" :aria-label="label || ''">
    <view
      class="kole-m-stepper__minus"
      :role="disabled || atMin ? '' : 'button'"
      :aria-disabled="disabled || atMin ? 'true' : 'false'"
      aria-label="减少"
      @tap="minus"
    >
      <text>−</text>
    </view>
    <input
      v-if="editable"
      class="kole-m-stepper__value-input"
      type="number"
      :value="String(value)"
      :disabled="disabled"
      :aria-label="label || '数量'"
      @input="onInput"
    />
    <text
      v-else
      class="kole-m-stepper__value"
      role="spinbutton"
      :aria-valuenow="value"
      :aria-valuemin="min"
      :aria-valuemax="max"
      >{{ value }}</text
    >
    <slot></slot>
    <view
      class="kole-m-stepper__plus"
      :role="disabled || atMax ? '' : 'button'"
      :aria-disabled="disabled || atMax ? 'true' : 'false'"
      aria-label="增加"
      @tap="plus"
    >
      <text>+</text>
    </view>
  </view>
</template>

<script setup>
/* uni-app 端 · 步进器(移动端)— 规格 §30
   跨端差异:
   ① 加减用 view + role="button"(小程序没有可聚焦的原生 button 语义),点击用 @tap;
   ② 到边界时不用原生 disabled(view 没有),改为 aria-disabled + is-disabled 类,
      代码里自己拦住点击 —— 按钮**留在原位灰**,不隐藏;
   ③ 值区用 <text>;editable 时用 uni 的 <input type="number">,
      事件对象是 { detail: { value } },没有 DOM event.target。
   尺寸用 rpx:88rpx = 375pt 下的 44px 触控最小边长。 */
import { computed } from 'vue';

const props = defineProps({
  value: { type: Number, default: 1 },
  min: { type: Number, default: 1 },
  max: { type: Number, default: 99 },
  step: { type: Number, default: 1 },
  size: { type: String, default: 'default' },
  round: { type: Boolean, default: false },
  editable: { type: Boolean, default: false },
  disabled: { type: Boolean, default: false },
  label: { type: String, default: '' }
});
const emit = defineEmits(['change', 'input']);

const atMin = computed(() => props.value <= props.min);
const atMax = computed(() => props.value >= props.max);

const stepperClass = computed(() => [
  props.size === 'small' ? 'kole-m-stepper--small' : '',
  props.round ? 'kole-m-stepper--round' : '',
  props.disabled ? 'is-disabled' : '',
  props.disabled || atMin.value ? 'is-minus-disabled' : '',
  props.disabled || atMax.value ? 'is-plus-disabled' : ''
].filter(Boolean));

function clamp(next) {
  return Math.max(props.min, Math.min(props.max, next));
}

function minus() {
  if (props.disabled || atMin.value) return;
  emit('change', clamp(props.value - props.step));
}

function plus() {
  if (props.disabled || atMax.value) return;
  emit('change', clamp(props.value + props.step));
}

/* uni 的 <input> 事件对象为 { detail: { value } },无 DOM event.target */
function onInput(e) {
  if (props.disabled) return;
  emit('input', e && e.detail ? e.detail.value : '');
}
</script>

<style>
.kole-m-stepper {
  --kole-m-stepper-height: 88rpx;  /* 控件高(88rpx = 44px,触控最小边长) */
  --kole-m-stepper-btn: 88rpx;     /* 单个加减按钮边长(44px 见方) */
  --kole-m-stepper-radius: 8rpx;
  --kole-m-touch-target: 88rpx;
  --kole-m-font-size-title: 34rpx;
  --kole-m-font-size-label: 28rpx;
  --kole-m-gutter: 32rpx;
  box-sizing: border-box;
  display: flex;
  align-items: stretch;
  min-height: var(--kole-m-stepper-height);
  border: 1rpx solid var(--kole-color-border);
  border-radius: var(--kole-m-stepper-radius);
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  overflow: hidden;
}

.kole-m-stepper--round { --kole-m-stepper-radius: 999rpx; }

/* 变体 size=small:视觉 72rpx(= 36px)紧凑;热区外扩回 88rpx */
.kole-m-stepper--small {
  --kole-m-stepper-height: 72rpx;
  --kole-m-stepper-btn: 72rpx;
}

.kole-m-stepper__minus,
.kole-m-stepper__plus {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--kole-m-stepper-btn);
  min-height: var(--kole-m-stepper-height);
  background-color: var(--kole-color-table-header-bg);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-title);
}

.kole-m-stepper__minus { border-right: 1rpx solid var(--kole-color-border); }
.kole-m-stepper__plus { border-left: 1rpx solid var(--kole-color-border); }

/* 到边界(或整组禁用)→ 原位置灰,不隐藏 */
.kole-m-stepper__minus.is-disabled,
.kole-m-stepper__plus.is-disabled,
.kole-m-stepper.is-minus-disabled .kole-m-stepper__minus,
.kole-m-stepper.is-plus-disabled .kole-m-stepper__plus {
  color: var(--kole-color-text-disabled);
  background-color: var(--kole-color-disabled-bg);
}

.kole-m-stepper.is-disabled .kole-m-stepper__value,
.kole-m-stepper.is-disabled .kole-m-stepper__value-input {
  color: var(--kole-color-text-disabled);
  background-color: var(--kole-color-disabled-bg);
}

.kole-m-stepper__value,
.kole-m-stepper__value-input {
  flex: 1;
  min-width: 88rpx;
  min-height: var(--kole-m-stepper-height);
  padding: 0 16rpx;
  text-align: center;
  font-size: 32rpx;
  color: var(--kole-color-text-body);
}

.kole-m-stepper__unit {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  padding-right: 24rpx;
  color: var(--kole-color-text-secondary);
  font-size: var(--kole-m-font-size-label);
}
</style>

测试与回归

断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。

断言 17 条 · 全部通过 报告 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-stepper.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "30 · 步进器 Stepper",
  "confidence": "high",
  "slug": "mobile-stepper",
  "name": "步进器 Stepper",
  "semanticTypeCandidates": [
    "stepper",
    "spinbutton",
    "quantity-input"
  ],
  "variantDimensions": [
    {
      "name": "size",
      "values": [
        "default",
        "small"
      ]
    },
    {
      "name": "round",
      "values": [
        "false",
        "true"
      ]
    }
  ],
  "representativeVariants": [
    {
      "size": "default",
      "round": "false",
      "label": "默认(控件高 44px,方角嵌在表单里)"
    },
    {
      "size": "default",
      "round": "true",
      "label": "全圆角(购物车等轻量场合)"
    },
    {
      "size": "small",
      "round": "false",
      "label": "紧凑(视觉 36px,热区外扩回 44px)"
    }
  ],
  "anatomy": {
    "stepper": "根元素,一行里放进「减号 + 值 + 加号」",
    "minus": "减号按钮,到达 min 时置灰",
    "value": "值区;editable=true 时是可聚焦的数字输入框,否则是纯文本",
    "plus": "加号按钮,到达 max 时置灰",
    "unit": "可选单位文案,跟在值后面(如「件」),由默认插槽给出",
    "label": "无障碍分组名称(role=\"group\" + aria-label)"
  },
  "structurePatterns": {
    "size": "default(控件高 44px)/ small(控件高 36px,视觉更紧凑)",
    "round": "false(方角)/ true(全圆角,用于购物车等轻量场合)"
  },
  "usageHints": [
    "在一段有界区间里连续加减小整数(购买数量、份数、编号)",
    "加号与减号必须各自独立占一个 44px 见方的热区",
    "到边界时不是把按钮藏起来,而是置灰 —— 触屏上「按钮消失」会让用户以为界面坏了",
    "单击步进一个 step,不响应长按连击(长按是桌面习惯,触屏上容易多跳)",
    "值变化后立即触发 change 事件,不做防抖"
  ],
  "doNotInvent": [
    "长按连击、惯性加速的时值曲线",
    "小数与浮点精度(本组件只处理整数;金额请用输入框 + 数字键盘)",
    "超出边界的提示文案(由宿主决定是否提示)"
  ],
  "unknowns": [
    "值为 0 时是否自动隐藏整个步进器(购物车场景)",
    "是否需要键盘上的上下方向键加减",
    "单位文案是否随语言变化"
  ],
  "interaction": [
    "加号与减号各自是 44px 见方的热区;size=small 视觉 36px 时用 --kole-m-hit-slack 把热区外扩回 44px",
    "单击步进一个 step,不响应长按连击(长按是桌面习惯,触屏上容易多跳)",
    "到达 min / max 时对应按钮置 disabled 且置灰,点击不产生值与事件",
    "值变化后立即触发 change 事件,不做防抖",
    "editable=true 时值区接受键盘直接输入;非法输入(非数字、越界)在失焦时回落到边界值"
  ],
  "accessibility": [
    "根元素 role=\"group\" + aria-label 说明这组控件在调什么",
    "减号 / 加号是原生 button,各自的 aria-label 是「减少」/「增加」(不用符号代替名称)",
    "值区在只读态置 role=\"spinbutton\" 并写 aria-valuenow / aria-valuemin / aria-valuemax",
    "置灰的按钮用原生 disabled,读屏会播报不可用且键盘会跳过"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "value",
        "type": "number",
        "default": "1",
        "desc": "当前值,受控;变化通过 change 回传(规格 §30.5)",
        "required": false
      },
      {
        "name": "min",
        "type": "number",
        "default": "1",
        "desc": "下界,到达时减号置灰(规格 §30.4)",
        "required": false
      },
      {
        "name": "max",
        "type": "number",
        "default": "99",
        "desc": "上界,到达时加号置灰(规格 §30.4)",
        "required": false
      },
      {
        "name": "step",
        "type": "number",
        "default": "1",
        "desc": "单次步进量(规格 §30.5)",
        "required": false
      },
      {
        "name": "size",
        "type": "'default' | 'small'",
        "default": "'default'",
        "desc": "变体 size:default 高 44px,small 视觉 36px(热区外扩回 44px)(规格 §30.3)",
        "required": false
      },
      {
        "name": "round",
        "type": "boolean",
        "default": "false",
        "desc": "变体 round:全圆角(规格 §30.3)",
        "required": false
      },
      {
        "name": "editable",
        "type": "boolean",
        "default": "false",
        "desc": "值区是否可键盘直接输入(规格 §30.5)",
        "required": false
      },
      {
        "name": "disabled",
        "type": "boolean",
        "default": "false",
        "desc": "状态 disabled:整组置灰且不可聚焦(规格 §30.4)",
        "required": false
      },
      {
        "name": "label",
        "type": "string",
        "default": "''",
        "desc": "无障碍分组名称,落到 role=\"group\" 的 aria-label(规格 §30.6)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "change",
        "params": "(value)",
        "desc": "点加号 / 减号后触发,回传夹到 min~max 的目标值(规格 §30.5)"
      },
      {
        "name": "input",
        "params": "(value)",
        "desc": "editable=true 时值区输入触发,回传原始文本(规格 §30.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "值后面的单位文案(如「件」),渲染在加减号之间(规格 §30.2 unit)"
      }
    ]
  },
  "variantClasses": {
    "size": {
      "default": [],
      "small": [
        ".kole-m-stepper--small"
      ]
    },
    "round": {
      "false": [],
      "true": [
        ".kole-m-stepper--round"
      ]
    }
  },
  "demos": [
    {
      "id": "basic",
      "group": "01 组件类型",
      "title": "基础用法",
      "desc": "减号 / 值 / 加号三段;加减各自是 44px 见方热区。",
      "variant": "size=default"
    },
    {
      "id": "bounds",
      "group": "02 组件状态",
      "title": "边界",
      "desc": "到 min / max 时按钮留在原位置灰而不隐藏:按钮消失会让用户以为界面坏了。",
      "variant": "状态 min|max"
    },
    {
      "id": "size",
      "group": "01 组件类型",
      "title": "圆角两态",
      "desc": "round=true 全圆角用于购物车;round=false 方角嵌在表单里。",
      "variant": "round=false|true"
    },
    {
      "id": "size-small",
      "group": "01 组件类型",
      "title": "紧凑尺寸",
      "desc": "size=small 视觉 36px,热区用 --kole-m-hit-slack 外扩回 44px。",
      "variant": "size=small"
    },
    {
      "id": "disabled",
      "group": "02 组件状态",
      "title": "禁用",
      "desc": "整组置灰、两个按钮都不可聚焦;读屏会播报不可用。",
      "variant": "disabled=true"
    }
  ],
  "related": [
    {
      "slug": "mobile-input",
      "why": "输入范围不固定、也不是整数时用输入框;有界的整数加减用步进器"
    },
    {
      "slug": "mobile-numberkeyboard",
      "why": "数量大到要手打时,输入框配套数字键盘;少量加减直接让步进器承担"
    },
    {
      "slug": "cell",
      "why": "步进器常作为单元格的右侧内容;行结构与点击进下级由单元格负责"
    }
  ]
}