移动端导航开关

开关Switch

即时启停一项配置或业务状态(启用通知、公开数据、自动同步)

数据录入 规格 29 · 开关 Switch 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-switch.css">

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

演示

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

01 组件类型

基础用法size=default

整行可点:开关本体只有 48×28,单点本体手指点不中。

查看代码(演示页原文 · 10 行)
frameworks-mobile/Switch.html · basic
<section class="demo-block" data-demo="basic">
  <p class="demo-label">基础用法(整行可点:开关本体只有 48×28,单点本体手指点不中)</p>
  <div class="demo-box">
    <button class="kole-m-switch is-on" type="button" role="switch" aria-checked="true" id="sw-product"
            data-assert="switch-basic" data-behavior="click-toggles-class:#sw-product|is-on">
      <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
      <span class="kole-m-switch__text">商品公开可见</span>
    </button>
  </div>
</section>
尺寸两档size=default|small

default 轨道 48×28;small 轨道 40×22 用于紧凑表单。

查看代码(演示页原文 · 13 行)
frameworks-mobile/Switch.html · size
<section class="demo-block" data-demo="size">
  <p class="demo-label">尺寸两档(size=default 48×28 / size=small 40×22,紧凑表单用)</p>
  <div class="demo-box" data-assert="switch-size">
    <button class="kole-m-switch is-on" type="button" role="switch" aria-checked="true">
      <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
      <span class="kole-m-switch__text">default(48×28)</span>
    </button>
    <button class="kole-m-switch kole-m-switch--small is-on" type="button" role="switch" aria-checked="true">
      <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
      <span class="kole-m-switch__text">small(40×22)</span>
    </button>
  </div>
</section>
文字在左labelPlacement=left

labelPlacement=left:整行右对齐的值区风格,文字在开关左侧。

查看代码(演示页原文 · 9 行)
frameworks-mobile/Switch.html · left
<section class="demo-block" data-demo="left">
  <p class="demo-label">文字在左(labelPlacement=left:整行右对齐的值区风格)</p>
  <div class="demo-box" data-assert="switch-left">
    <button class="kole-m-switch kole-m-switch--left is-on" type="button" role="switch" aria-checked="true">
      <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
      <span class="kole-m-switch__text">自动同步(成功后写入云端)</span>
    </button>
  </div>
</section>

02 组件状态

关态与开态状态 off|on

两者不能只靠颜色区分:滑块位置 + aria-checked 双通道。

查看代码(演示页原文 · 14 行)
frameworks-mobile/Switch.html · states
<section class="demo-block" data-demo="states">
  <p class="demo-label">关态与开态(两者不能只靠颜色区分:滑块位置 + aria-checked 双通道)</p>
  <div class="demo-box" data-assert="switch-states">
    <button class="kole-m-switch" type="button" role="switch" aria-checked="false" data-switch="off">
      <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
      <span class="kole-m-switch__text">关态(默认)</span>
    </button>
    <button class="kole-m-switch is-on" type="button" role="switch" aria-checked="true" id="sw-on" data-switch="on"
            data-behavior="click-sets-attr:#sw-on|aria-checked|false">
      <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
      <span class="kole-m-switch__text">开态(滑块右移 + 品牌底色)</span>
    </button>
  </div>
</section>
禁用disabled=true

置灰且不响应;读屏会播报不可用。

查看代码(演示页原文 · 10 行)
frameworks-mobile/Switch.html · disabled
<section class="demo-block" data-demo="disabled">
  <p class="demo-label">禁用(置灰且不响应;读屏会播报不可用)</p>
  <div class="demo-box" data-assert="switch-disabled">
    <button class="kole-m-switch kole-m-switch--small is-disabled" type="button" role="switch" aria-checked="false"
            aria-disabled="true" disabled>
      <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
      <span class="kole-m-switch__text">内测功能(需管理员开启)</span>
    </button>
  </div>
</section>

API

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

Props

名称类型默认值说明必传
checkedbooleanfalse受控开关值;与 aria-checked 同步写(规格 §29.5)N
size'default' | 'small''default'变体 size:default 轨道 48×28,small 轨道 40×22(规格 §29.3)N
labelPlacement'right' | 'left''right'变体 labelPlacement:文字在开关右侧还是左侧(规格 §29.3)N
disabledbooleanfalse状态 disabled:置灰且不可聚焦(规格 §29.4)N
labelstring''文字标签;同时作为无障碍名称落到 aria-label(规格 §29.6)N

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

事件

名称参数说明
change(checked)切换时触发,回传切换后的目标值(规格 §29.5)

插槽

名称说明
default文字标签内容,优先于 label 属性(规格 §29.2 text)

CSS 变量

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

名称默认值说明
--kole-m-switch-track-w48px轨道宽
--kole-m-switch-track-h28px轨道高
--kole-m-switch-knob24px滑块直径(轨道高 − 2×2px 内边距)
--kole-m-switch-duration150ms滑块位移与底色过渡时长
--kole-m-switch-track-w40px组件内部默认值,可在业务侧覆盖
--kole-m-switch-track-h22px组件内部默认值,可在业务侧覆盖
--kole-m-switch-knob18px组件内部默认值,可在业务侧覆盖

何时使用

  • 即时启停一项配置或业务状态(启用通知、公开数据、自动同步)
  • 移动端开关本体视觉只有 48×28,但整行(开关 + 文字)都是可点热区,行高不小于 44px
  • 关态与开态不能只靠颜色区分,必须同时看到滑块位移
  • 点击切换只需要一次触摸,不要求拖动滑块(拖动是桌面习惯,触屏误触率高)
  • 切换后立即触发 change 事件,不做二次确认(需要确认的场景由宿主先弹对话框)

交互与触控

  • 整行(开关 + 文字)都是热区,行高不小于 44px;开关本体不可单独缩到 44px 以下
  • 点击切换只需要一次触摸,不要求拖动滑块(拖动是桌面习惯,触屏误触率高)
  • 切换动效是滑块位移 150ms 过渡;减少动态偏好下瞬时切换
  • 关态与开态不能只靠颜色区分:滑块位置 + aria-checked 双通道
  • 切换后立即触发 change 事件,不做二次确认(需要确认的场景由宿主先弹对话框)

无障碍

  • 用 role="switch" + aria-checked="true|false",而不是 role="checkbox"(读屏会播报「开关」)
  • 承载控件是原生 button,键盘可聚焦、空格/回车可切换,并有可见焦点环
  • 文字标签在控件内部,读屏播报的名称就是标签本身;无标签时用 label 属性补 aria-label
  • 禁用态用原生 disabled,读屏会播报不可用

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

组件何时用它而不是本组件
单元格Cell开关常作为单元格的右侧内容(Cell 负责行结构与分隔线,开关只管切换)
按钮Button需要用户确认后一次性提交的用按钮;状态需要即时生效的用开关
对话框Dialog切换前需要用户确认(如扣费项)时先弹对话框;开关本身不做二次确认

规格未定 / 禁止发明

类别条目
禁止发明二次确认弹窗与「切换失败回滚」的业务流程
禁止发明三态开关(关 / 开 / 待定)的视觉表达
禁止发明与表单一起提交时的隐藏字段(由宿主添加)
规格未定开关本体是否允许小于 48×28(紧凑表单的下限)
规格未定文案与开关的间距是否跟随字号
规格未定加载态(切换请求进行中)如何表达

结构(anatomy)

字段说明
switch根元素,一行里放进「轨道 + 文字标签」,整行可点
track轨道,承载背景色与滑块位移的边界
knob滑块,关态靠左、开态靠右(位移是开/关的主要视觉信号)
text可选文字标签,说明这项开关控制什么
control可点的整行控件(原生 button + role="switch"),承接键盘与读屏

变体维度与类名映射

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

维度取值对应类名 / 变量
sizedefault / small
default (由数据驱动,无专属类)
small .kole-m-switch--small
labelPlacementright / left
right (由数据驱动,无专属类)
left .kole-m-switch--left

代表变体

变体标签
size=default · labelPlacement=right默认(轨道 48×28,文字在右)
size=small · labelPlacement=right紧凑(轨道 40×22,紧凑表单用)
size=default · labelPlacement=left文字在左(整行右对齐的值区风格)

用到的令牌

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

--kole-m-font-size-body --kole-m-gutter --kole-m-touch-target --kole-color-brand --kole-color-card-bg --kole-color-focus-ring --kole-color-icon-inactive --kole-color-table-header-bg --kole-color-text-body --kole-color-text-disabled --kole-ease-standard --kole-shadow-low --kole-space-12 --kole-space-8 --kole-m-switch-duration --kole-m-switch-knob --kole-m-switch-track-h --kole-m-switch-track-w

6 端源码

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

frameworks-mobile/Switch.css · 纯样式(CSS) · 98 行
frameworks-mobile/Switch.css
/* Kole UI Mobile · Switch 样式 — 对齐移动端规格 §29
   开关:轨道 + 滑块,开态用「滑块位移 + 品牌底色」双通道表达(不靠颜色单独表意);
   整行(开关 + 文字)是热区,行高不小于 44px —— 开关本体视觉只有 48×28,单点本体手指点不中;
   控件是原生 button + role="switch" + aria-checked(读屏播报「开关」,不是「复选框」)。 */

.kole-m-switch {
  /* 组件级变量:业务侧可在容器上覆盖 */
  --kole-m-switch-track-w: 48px;   /* 轨道宽 */
  --kole-m-switch-track-h: 28px;   /* 轨道高 */
  --kole-m-switch-knob: 24px;      /* 滑块直径(轨道高 − 2×2px 内边距) */
  --kole-m-switch-duration: 150ms; /* 滑块位移与底色过渡时长 */
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: var(--kole-space-12);
  width: 100%;
  min-height: var(--kole-m-touch-target);
  padding: var(--kole-space-8) var(--kole-m-gutter);
  border: 0;
  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;
  text-align: start;
  cursor: pointer;
  touch-action: manipulation;
}

/* 变体 labelPlacement=left:文字在左、开关在右(整行右对齐的值区风格) */
.kole-m-switch--left { flex-direction: row-reverse; }

/* 变体 labelPlacement=left 时文字占满,开关贴右 */
.kole-m-switch--left .kole-m-switch__text { text-align: end; }

.kole-m-switch__track {
  position: relative;
  flex: 0 0 auto;
  display: inline-block;
  box-sizing: border-box;
  width: var(--kole-m-switch-track-w);
  height: var(--kole-m-switch-track-h);
  border-radius: 999px;
  /* 关态轨道用「未选中图标」色(3.23:1):开关状态承载信息,需满足 WCAG 1.4.11 的 3:1 */
  background: var(--kole-color-icon-inactive);
  transition: background var(--kole-m-switch-duration) var(--kole-ease-standard);
}

.kole-m-switch__knob {
  position: absolute;
  top: 2px;
  left: 2px;
  box-sizing: border-box;
  width: var(--kole-m-switch-knob);
  height: var(--kole-m-switch-knob);
  border-radius: 50%;
  background: var(--kole-color-card-bg);
  box-shadow: var(--kole-shadow-low);
  transition: transform var(--kole-m-switch-duration) var(--kole-ease-standard);
}

.kole-m-switch__text { flex: 1 1 auto; min-width: 0; }

/* 状态 on:滑块右移 + 轨道变品牌色(与 aria-checked="true" 同步写) */
.kole-m-switch.is-on .kole-m-switch__track { background: var(--kole-color-brand); }

.kole-m-switch.is-on .kole-m-switch__knob {
  transform: translateX(calc(var(--kole-m-switch-track-w) - var(--kole-m-switch-knob) - 4px));
}

/* 变体 size=small:紧凑表单用(轨道 40×22) */
.kole-m-switch--small {
  --kole-m-switch-track-w: 40px;
  --kole-m-switch-track-h: 22px;
  --kole-m-switch-knob: 18px;
}

/* 状态 disabled:置灰且不响应 */
.kole-m-switch.is-disabled {
  color: var(--kole-color-text-disabled);
  cursor: not-allowed;
}

.kole-m-switch.is-disabled:active { background: var(--kole-color-card-bg); }

.kole-m-switch:active:not(.is-disabled) { background: var(--kole-color-table-header-bg); }

.kole-m-switch:focus-visible {
  outline: 2px solid var(--kole-color-focus-ring);
  outline-offset: -2px;
}

/* 减少动态偏好:滑块瞬时到位,不做过场动画 */
@media (prefers-reduced-motion: reduce) {
  .kole-m-switch__track,
  .kole-m-switch__knob { transition: none; }
}
frameworks-mobile/Switch.html · H5 原生(无框架) · 123 行
frameworks-mobile/Switch.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 · Switch(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Switch.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 { background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="basic">
    <p class="demo-label">基础用法(整行可点:开关本体只有 48×28,单点本体手指点不中)</p>
    <div class="demo-box">
      <button class="kole-m-switch is-on" type="button" role="switch" aria-checked="true" id="sw-product"
              data-assert="switch-basic" data-behavior="click-toggles-class:#sw-product|is-on">
        <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
        <span class="kole-m-switch__text">商品公开可见</span>
      </button>
    </div>
  </section>

  <section class="demo-block" data-demo="states">
    <p class="demo-label">关态与开态(两者不能只靠颜色区分:滑块位置 + aria-checked 双通道)</p>
    <div class="demo-box" data-assert="switch-states">
      <button class="kole-m-switch" type="button" role="switch" aria-checked="false" data-switch="off">
        <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
        <span class="kole-m-switch__text">关态(默认)</span>
      </button>
      <button class="kole-m-switch is-on" type="button" role="switch" aria-checked="true" id="sw-on" data-switch="on"
              data-behavior="click-sets-attr:#sw-on|aria-checked|false">
        <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
        <span class="kole-m-switch__text">开态(滑块右移 + 品牌底色)</span>
      </button>
    </div>
  </section>

  <section class="demo-block" data-demo="size">
    <p class="demo-label">尺寸两档(size=default 48×28 / size=small 40×22,紧凑表单用)</p>
    <div class="demo-box" data-assert="switch-size">
      <button class="kole-m-switch is-on" type="button" role="switch" aria-checked="true">
        <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
        <span class="kole-m-switch__text">default(48×28)</span>
      </button>
      <button class="kole-m-switch kole-m-switch--small is-on" type="button" role="switch" aria-checked="true">
        <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
        <span class="kole-m-switch__text">small(40×22)</span>
      </button>
    </div>
  </section>

  <section class="demo-block" data-demo="left">
    <p class="demo-label">文字在左(labelPlacement=left:整行右对齐的值区风格)</p>
    <div class="demo-box" data-assert="switch-left">
      <button class="kole-m-switch kole-m-switch--left is-on" type="button" role="switch" aria-checked="true">
        <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
        <span class="kole-m-switch__text">自动同步(成功后写入云端)</span>
      </button>
    </div>
  </section>

  <section class="demo-block" data-demo="disabled">
    <p class="demo-label">禁用(置灰且不响应;读屏会播报不可用)</p>
    <div class="demo-box" data-assert="switch-disabled">
      <button class="kole-m-switch kole-m-switch--small is-disabled" type="button" role="switch" aria-checked="false"
              aria-disabled="true" disabled>
        <span class="kole-m-switch__track" aria-hidden="true"><span class="kole-m-switch__knob"></span></span>
        <span class="kole-m-switch__text">内测功能(需管理员开启)</span>
      </button>
    </div>
  </section>
</div>
<script>
  /* 演示页脚本:真实的切换。
     - 点击:切 is-on + aria-checked(两者必须同步写,只切类会让读屏读到旧状态)
     - 禁用项不响应(原生 disabled 已拦住 click,这里再兜一次)
     真实业务里这份状态由宿主管理(受控 checked + change 事件),此处是最小可运行实现。 */
  (function () {
    function sync(btn) {
      var on = btn.classList.contains('is-on');
      btn.setAttribute('aria-checked', on ? 'true' : 'false');
    }

    Array.prototype.forEach.call(document.querySelectorAll('.kole-m-switch'), function (btn) {
      if (btn.disabled || btn.classList.contains('is-disabled')) return;
      btn.addEventListener('click', function () {
        btn.classList.toggle('is-on');
        sync(btn);
      });
    });
  })();
</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/Switch.jsx · React · 47 行
frameworks-mobile/Switch.jsx
import React from 'react';
import './Switch.css';

/* 开关(移动端)— 规格 §29
   控件是原生 button + role="switch" + aria-checked(读屏播报「开关」,不是「复选框」)。
   整行(轨道 + 文字)都是热区,行高不小于 44px —— 开关本体视觉只有 48×28。
   开态用「滑块位移 + 品牌底色」双通道表达,不靠颜色单独表意。
   本端只回传切换意图(onChange),是否真的生效由宿主决定(规格 §29.5)。 */

export default function Switch({
  checked = false,
  size = 'default',
  labelPlacement = 'right',
  disabled = false,
  label = '',
  onChange,
  children = null,
}) {
  const cls =
    'kole-m-switch' +
    (size === 'small' ? ' kole-m-switch--small' : '') +
    (labelPlacement === 'left' ? ' kole-m-switch--left' : '') +
    (checked ? ' is-on' : '') +
    (disabled ? ' is-disabled' : '');

  return (
    <button
      className={cls}
      type="button"
      role="switch"
      aria-checked={checked ? 'true' : 'false'}
      aria-label={label || undefined}
      aria-disabled={disabled ? 'true' : undefined}
      disabled={disabled}
      onClick={() => {
        if (disabled) return;
        if (onChange) onChange(!checked);
      }}
    >
      <span className="kole-m-switch__track" aria-hidden="true">
        <span className="kole-m-switch__knob" />
      </span>
      {children || label ? <span className="kole-m-switch__text">{children || label}</span> : null}
    </button>
  );
}
frameworks-mobile/Switch.vue2.vue · Vue 2 · 56 行
frameworks-mobile/Switch.vue2.vue
<template>
  <button
    class="kole-m-switch"
    :class="switchClass"
    type="button"
    role="switch"
    :aria-checked="checked ? 'true' : 'false'"
    :aria-label="label || null"
    :aria-disabled="disabled ? 'true' : null"
    :disabled="disabled"
    @click="onClick"
  >
    <span class="kole-m-switch__track" aria-hidden="true">
      <span class="kole-m-switch__knob"></span>
    </span>
    <span v-if="$slots.default || label" class="kole-m-switch__text"><slot>{{ label }}</slot></span>
  </button>
</template>

<script>
/* 开关(移动端)— 规格 §29
   控件是原生 button + role="switch" + aria-checked(读屏播报「开关」,不是「复选框」)。
   整行(轨道 + 文字)都是热区,行高不小于 44px —— 开关本体视觉只有 48×28。
   开态用「滑块位移 + 品牌底色」双通道表达,不靠颜色单独表意。
   本端只回传切换意图(change),是否真的生效由宿主决定(规格 §29.5)。 */

export default {
  name: 'KoleMSwitch',
  props: {
    checked: { type: Boolean, default: false },
    size: { type: String, default: 'default' },
    labelPlacement: { type: String, default: 'right' },
    disabled: { type: Boolean, default: false },
    label: { type: String, default: '' }
  },
  computed: {
    switchClass: function () {
      return [
        this.size === 'small' ? 'kole-m-switch--small' : '',
        this.labelPlacement === 'left' ? 'kole-m-switch--left' : '',
        this.checked ? 'is-on' : '',
        this.disabled ? 'is-disabled' : ''
      ].filter(Boolean);
    }
  },
  methods: {
    onClick: function () {
      if (this.disabled) return;
      this.$emit('change', !this.checked);
    }
  }
};
</script>

<style src="./Switch.css"></style>
frameworks-mobile/Switch.vue3.vue · Vue 3 · 51 行
frameworks-mobile/Switch.vue3.vue
<template>
  <button
    class="kole-m-switch"
    :class="switchClass"
    type="button"
    role="switch"
    :aria-checked="checked ? 'true' : 'false'"
    :aria-label="label || null"
    :aria-disabled="disabled ? 'true' : null"
    :disabled="disabled"
    @click="onClick"
  >
    <span class="kole-m-switch__track" aria-hidden="true">
      <span class="kole-m-switch__knob"></span>
    </span>
    <span v-if="$slots.default || label" class="kole-m-switch__text"><slot>{{ label }}</slot></span>
  </button>
</template>

<script setup>
/* 开关(移动端)— 规格 §29
   控件是原生 button + role="switch" + aria-checked(读屏播报「开关」,不是「复选框」)。
   整行(轨道 + 文字)都是热区,行高不小于 44px —— 开关本体视觉只有 48×28。
   开态用「滑块位移 + 品牌底色」双通道表达,不靠颜色单独表意。
   本端只回传切换意图(change),是否真的生效由宿主决定(规格 §29.5)。 */
import { computed } from 'vue';

const props = defineProps({
  checked: { type: Boolean, default: false },
  size: { type: String, default: 'default' },
  labelPlacement: { type: String, default: 'right' },
  disabled: { type: Boolean, default: false },
  label: { type: String, default: '' }
});
const emit = defineEmits(['change']);

const switchClass = computed(() => [
  props.size === 'small' ? 'kole-m-switch--small' : '',
  props.labelPlacement === 'left' ? 'kole-m-switch--left' : '',
  props.checked ? 'is-on' : '',
  props.disabled ? 'is-disabled' : ''
].filter(Boolean));

function onClick() {
  if (props.disabled) return;
  emit('change', !props.checked);
}
</script>

<style src="./Switch.css"></style>
frameworks-mobile/Switch.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 121 行
frameworks-mobile/Switch.uniapp.vue
<template>
  <view
    class="kole-m-switch"
    :class="switchClass"
    :role="disabled ? '' : 'switch'"
    :aria-checked="checked ? 'true' : 'false'"
    :aria-disabled="disabled ? 'true' : 'false'"
    :aria-label="label || ''"
    @tap="onTap"
  >
    <view class="kole-m-switch__track">
      <view class="kole-m-switch__knob"></view>
    </view>
    <text v-if="label" class="kole-m-switch__text">{{ label }}</text>
    <slot></slot>
  </view>
</template>

<script setup>
/* uni-app 端 · 开关(移动端)— 规格 §29
   跨端差异:
   ① 小程序端不用 uni 的 <switch> 基础组件:它无法承载「文字在左 / 紧凑尺寸」这类形态,
      也带不进 kole-m- 的样式与令牌,故用 view 自绘 + role="switch" + aria-checked;
   ② 点击用 @tap(触屏),不依赖 pointer/mouse;
   ③ 滑块位移与底色过渡由 CSS transition 承担,代码里不做动画。
   尺寸用 rpx:88rpx = 375pt 下的 44px 触控最小边长,故整行最小高 88rpx,
   轨道 96rpx×56rpx(= 48×28),滑块 48rpx(= 24)。 */
import { computed } from 'vue';

const props = defineProps({
  checked: { type: Boolean, default: false },
  size: { type: String, default: 'default' },
  labelPlacement: { type: String, default: 'right' },
  disabled: { type: Boolean, default: false },
  label: { type: String, default: '' }
});
const emit = defineEmits(['change']);

const switchClass = computed(() => [
  props.size === 'small' ? 'kole-m-switch--small' : '',
  props.labelPlacement === 'left' ? 'kole-m-switch--left' : '',
  props.checked ? 'is-on' : '',
  props.disabled ? 'is-disabled' : ''
].filter(Boolean));

function onTap() {
  if (props.disabled) return;
  emit('change', !props.checked);
}
</script>

<style>
.kole-m-switch {
  --kole-m-switch-track-w: 96rpx;   /* 轨道宽(96rpx = 48px) */
  --kole-m-switch-track-h: 56rpx;   /* 轨道高(56rpx = 28px) */
  --kole-m-switch-knob: 48rpx;      /* 滑块直径(48rpx = 24px) */
  --kole-m-touch-target: 88rpx;
  --kole-m-font-size-body: 32rpx;
  --kole-m-gutter: 32rpx;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  width: 100%;
  min-height: var(--kole-m-touch-target);
  padding: 16rpx var(--kole-m-gutter);
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-body);
}

.kole-m-switch--left { flex-direction: row-reverse; }
.kole-m-switch--left .kole-m-switch__text { text-align: right; }

.kole-m-switch__track {
  position: relative;
  flex-shrink: 0;
  width: var(--kole-m-switch-track-w);
  height: var(--kole-m-switch-track-h);
  border-radius: 999rpx;
  background-color: var(--kole-color-icon-inactive);
  transition: background-color 150ms ease;
}

.kole-m-switch__knob {
  position: absolute;
  top: 4rpx;
  left: 4rpx;
  width: var(--kole-m-switch-knob);
  height: var(--kole-m-switch-knob);
  border-radius: 50%;
  background-color: var(--kole-color-card-bg);
  transition: transform 150ms ease;
}

/* 状态 on:滑块右移 + 轨道变品牌色 */
.kole-m-switch.is-on .kole-m-switch__track { background-color: var(--kole-color-brand); }

.kole-m-switch.is-on .kole-m-switch__knob {
  transform: translateX(calc(var(--kole-m-switch-track-w) - var(--kole-m-switch-knob) - 8rpx));
}

/* 变体 size=small:通道 80rpx×44rpx(= 40×22),滑块 36rpx(= 18) */
.kole-m-switch--small {
  --kole-m-switch-track-w: 80rpx;
  --kole-m-switch-track-h: 44rpx;
  --kole-m-switch-knob: 36rpx;
}

.kole-m-switch__text {
  flex: 1;
  padding-left: 24rpx;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.kole-m-switch--left .kole-m-switch__text { padding-left: 0; padding-right: 24rpx; }

.kole-m-switch.is-disabled { color: var(--kole-color-text-disabled); }
</style>

测试与回归

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

断言 17 条 · 全部通过 报告 2026-09-22 23:01:05

复现命令
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-switch.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "29 · 开关 Switch",
  "confidence": "high",
  "slug": "mobile-switch",
  "name": "开关 Switch",
  "semanticTypeCandidates": [
    "switch",
    "toggle",
    "boolean-input"
  ],
  "variantDimensions": [
    {
      "name": "size",
      "values": [
        "default",
        "small"
      ]
    },
    {
      "name": "labelPlacement",
      "values": [
        "right",
        "left"
      ]
    }
  ],
  "representativeVariants": [
    {
      "size": "default",
      "labelPlacement": "right",
      "label": "默认(轨道 48×28,文字在右)"
    },
    {
      "size": "small",
      "labelPlacement": "right",
      "label": "紧凑(轨道 40×22,紧凑表单用)"
    },
    {
      "size": "default",
      "labelPlacement": "left",
      "label": "文字在左(整行右对齐的值区风格)"
    }
  ],
  "anatomy": {
    "switch": "根元素,一行里放进「轨道 + 文字标签」,整行可点",
    "track": "轨道,承载背景色与滑块位移的边界",
    "knob": "滑块,关态靠左、开态靠右(位移是开/关的主要视觉信号)",
    "text": "可选文字标签,说明这项开关控制什么",
    "control": "可点的整行控件(原生 button + role=\"switch\"),承接键盘与读屏"
  },
  "structurePatterns": {
    "size": "default(轨道 48×28)/ small(轨道 40×22,用于紧凑表单)",
    "labelPlacement": "right(文字在开关右侧,默认)/ left(文字在左侧,值区右对齐时用)"
  },
  "usageHints": [
    "即时启停一项配置或业务状态(启用通知、公开数据、自动同步)",
    "移动端开关本体视觉只有 48×28,但整行(开关 + 文字)都是可点热区,行高不小于 44px",
    "关态与开态不能只靠颜色区分,必须同时看到滑块位移",
    "点击切换只需要一次触摸,不要求拖动滑块(拖动是桌面习惯,触屏误触率高)",
    "切换后立即触发 change 事件,不做二次确认(需要确认的场景由宿主先弹对话框)"
  ],
  "doNotInvent": [
    "二次确认弹窗与「切换失败回滚」的业务流程",
    "三态开关(关 / 开 / 待定)的视觉表达",
    "与表单一起提交时的隐藏字段(由宿主添加)"
  ],
  "unknowns": [
    "开关本体是否允许小于 48×28(紧凑表单的下限)",
    "文案与开关的间距是否跟随字号",
    "加载态(切换请求进行中)如何表达"
  ],
  "interaction": [
    "整行(开关 + 文字)都是热区,行高不小于 44px;开关本体不可单独缩到 44px 以下",
    "点击切换只需要一次触摸,不要求拖动滑块(拖动是桌面习惯,触屏误触率高)",
    "切换动效是滑块位移 150ms 过渡;减少动态偏好下瞬时切换",
    "关态与开态不能只靠颜色区分:滑块位置 + aria-checked 双通道",
    "切换后立即触发 change 事件,不做二次确认(需要确认的场景由宿主先弹对话框)"
  ],
  "accessibility": [
    "用 role=\"switch\" + aria-checked=\"true|false\",而不是 role=\"checkbox\"(读屏会播报「开关」)",
    "承载控件是原生 button,键盘可聚焦、空格/回车可切换,并有可见焦点环",
    "文字标签在控件内部,读屏播报的名称就是标签本身;无标签时用 label 属性补 aria-label",
    "禁用态用原生 disabled,读屏会播报不可用"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "checked",
        "type": "boolean",
        "default": "false",
        "desc": "受控开关值;与 aria-checked 同步写(规格 §29.5)",
        "required": false
      },
      {
        "name": "size",
        "type": "'default' | 'small'",
        "default": "'default'",
        "desc": "变体 size:default 轨道 48×28,small 轨道 40×22(规格 §29.3)",
        "required": false
      },
      {
        "name": "labelPlacement",
        "type": "'right' | 'left'",
        "default": "'right'",
        "desc": "变体 labelPlacement:文字在开关右侧还是左侧(规格 §29.3)",
        "required": false
      },
      {
        "name": "disabled",
        "type": "boolean",
        "default": "false",
        "desc": "状态 disabled:置灰且不可聚焦(规格 §29.4)",
        "required": false
      },
      {
        "name": "label",
        "type": "string",
        "default": "''",
        "desc": "文字标签;同时作为无障碍名称落到 aria-label(规格 §29.6)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "change",
        "params": "(checked)",
        "desc": "切换时触发,回传切换后的目标值(规格 §29.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "文字标签内容,优先于 label 属性(规格 §29.2 text)"
      }
    ]
  },
  "variantClasses": {
    "size": {
      "default": [],
      "small": [
        ".kole-m-switch--small"
      ]
    },
    "labelPlacement": {
      "right": [],
      "left": [
        ".kole-m-switch--left"
      ]
    }
  },
  "demos": [
    {
      "id": "basic",
      "group": "01 组件类型",
      "title": "基础用法",
      "desc": "整行可点:开关本体只有 48×28,单点本体手指点不中。",
      "variant": "size=default"
    },
    {
      "id": "states",
      "group": "02 组件状态",
      "title": "关态与开态",
      "desc": "两者不能只靠颜色区分:滑块位置 + aria-checked 双通道。",
      "variant": "状态 off|on"
    },
    {
      "id": "size",
      "group": "01 组件类型",
      "title": "尺寸两档",
      "desc": "default 轨道 48×28;small 轨道 40×22 用于紧凑表单。",
      "variant": "size=default|small"
    },
    {
      "id": "left",
      "group": "01 组件类型",
      "title": "文字在左",
      "desc": "labelPlacement=left:整行右对齐的值区风格,文字在开关左侧。",
      "variant": "labelPlacement=left"
    },
    {
      "id": "disabled",
      "group": "02 组件状态",
      "title": "禁用",
      "desc": "置灰且不响应;读屏会播报不可用。",
      "variant": "disabled=true"
    }
  ],
  "related": [
    {
      "slug": "cell",
      "why": "开关常作为单元格的右侧内容(Cell 负责行结构与分隔线,开关只管切换)"
    },
    {
      "slug": "mobile-button",
      "why": "需要用户确认后一次性提交的用按钮;状态需要即时生效的用开关"
    },
    {
      "slug": "mobile-dialog",
      "why": "切换前需要用户确认(如扣费项)时先弹对话框;开关本身不做二次确认"
    }
  ]
}