移动端导航选择器

选择器Picker

从一组有限且已知的选项里选出一项或几项(城市、分值、时间),是表单里下拉框在触屏上的形态

数据录入 规格 43 · 选择器 Picker 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-picker.css">

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

演示

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

01 组件类型

单列滚轮mode=single

mode=single:点选项即高亮,选中项用品牌色并与 aria-selected 同步,不依赖视觉位置。

查看代码(演示页原文 · 29 行)
frameworks-mobile/Picker.html · single
<section class="demo-block" data-demo="single">
  <p class="demo-label">单列滚轮(mode=single:常用城市;点选项即高亮,确认前不改宿主的值)</p>
  <div class="demo-frame" data-assert="picker-single">
    <div class="demo-frame__body">
      <p>已选城市:<span class="demo-value" id="pk-single-value">上海</span></p>
    </div>
    <div class="kole-m-picker__mask is-open" aria-hidden="true"></div>
    <div class="kole-m-picker kole-m-picker--single kole-m-picker--round is-open" id="pk-single"
         role="dialog" aria-modal="true" aria-labelledby="pk-single-title">
      <div class="kole-m-picker__header">
        <button class="kole-m-picker__btn" type="button" id="pk-single-cancel"
                data-behavior="click-sets-attr:#pk-single|data-cancelled|true">取消</button>
        <span class="kole-m-picker__title" id="pk-single-title">选择城市</span>
        <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button" id="pk-single-confirm"
                data-behavior="click-sets-attr:#pk-single|data-confirmed|true">确定</button>
      </div>
      <div class="kole-m-picker__columns">
        <ul class="kole-m-picker__column" role="listbox" aria-label="城市">
          <li class="kole-m-picker__option" role="option" aria-selected="false">北京</li>
          <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">上海</li>
          <li class="kole-m-picker__option" role="option" aria-selected="false" id="pk-single-opt-hz"
              data-behavior="click-sets-attr:#pk-single-opt-hz|aria-selected|true">杭州</li>
          <li class="kole-m-picker__option" role="option" aria-selected="false">成都</li>
          <li class="kole-m-picker__option" role="option" aria-selected="false">广州</li>
        </ul>
      </div>
    </div>
  </div>
</section>
多列滚轮mode=multiple

mode=multiple:时 / 分两列各自独立选中,确认时一并回传。

查看代码(演示页原文 · 30 行)
frameworks-mobile/Picker.html · multiple
<section class="demo-block" data-demo="multiple">
  <p class="demo-label">多列滚轮(mode=multiple:时 / 分两列,各列独立选中)</p>
  <div class="demo-frame" data-assert="picker-multiple">
    <div class="demo-frame__body">
      <p>已选时间:<span class="demo-value" id="pk-multiple-value">09:30</span></p>
    </div>
    <div class="kole-m-picker__mask is-open" aria-hidden="true"></div>
    <div class="kole-m-picker kole-m-picker--multiple kole-m-picker--round is-open" id="pk-multiple"
         role="dialog" aria-modal="true" aria-labelledby="pk-multiple-title">
      <div class="kole-m-picker__header">
        <button class="kole-m-picker__btn" type="button">取消</button>
        <span class="kole-m-picker__title" id="pk-multiple-title">选择时间</span>
        <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button">确定</button>
      </div>
      <div class="kole-m-picker__columns">
        <ul class="kole-m-picker__column" role="listbox" aria-label="时">
          <li class="kole-m-picker__option" role="option" aria-selected="false">08</li>
          <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">09</li>
          <li class="kole-m-picker__option" role="option" aria-selected="false">10</li>
        </ul>
        <ul class="kole-m-picker__column" role="listbox" aria-label="分">
          <li class="kole-m-picker__option" role="option" aria-selected="false">00</li>
          <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">30</li>
          <li class="kole-m-picker__option" role="option" aria-selected="false" id="pk-multiple-opt-45"
              data-behavior="click-sets-attr:#pk-multiple-opt-45|aria-selected|true">45</li>
        </ul>
      </div>
    </div>
  </div>
</section>

02 组件状态

选中态与禁用项状态 selected | disabled

选中用 is-selected + aria-selected 双通道;禁用项置灰并写 aria-disabled,点击不响应。

查看代码(演示页原文 · 21 行)
frameworks-mobile/Picker.html · selected
<section class="demo-block" data-demo="selected">
  <p class="demo-label">选中态与禁用项(选中 = is-selected + aria-selected 双通道;禁用项置灰且不响应)</p>
  <div class="demo-frame" data-assert="picker-selected">
    <div class="kole-m-picker__mask is-open" aria-hidden="true"></div>
    <div class="kole-m-picker kole-m-picker--single kole-m-picker--round is-open"
         role="dialog" aria-modal="true" aria-labelledby="pk-selected-title">
      <div class="kole-m-picker__header">
        <button class="kole-m-picker__btn" type="button">取消</button>
        <span class="kole-m-picker__title" id="pk-selected-title">选择面额</span>
        <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button">确定</button>
      </div>
      <div class="kole-m-picker__columns">
        <ul class="kole-m-picker__column" role="listbox" aria-label="面额">
          <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">¥ 50</li>
          <li class="kole-m-picker__option" role="option" aria-selected="false">¥ 100</li>
          <li class="kole-m-picker__option is-disabled" role="option" aria-selected="false" aria-disabled="true">¥ 200(已售罄)</li>
        </ul>
      </div>
    </div>
  </div>
</section>
收起态状态 closed

默认态:遮罩 pointer-events: none,点触发器才把浮层与遮罩一起拉开。

查看代码(演示页原文 · 25 行)
frameworks-mobile/Picker.html · closed
<section class="demo-block" data-demo="closed">
  <p class="demo-label">收起态(status=closed:遮罩 pointer-events: none,点触发器才拉起浮层)</p>
  <div class="demo-frame" data-assert="picker-closed">
    <div class="demo-frame__body">
      <p>收起时页面可正常滚动与点击。</p>
      <button class="kole-m-picker__trigger" type="button" id="pk-closed-trigger"
              data-behavior="click-toggles-class:#pk-closed|is-open">选择城市</button>
    </div>
    <div class="kole-m-picker__mask" id="pk-closed-mask" aria-hidden="true"></div>
    <div class="kole-m-picker kole-m-picker--single kole-m-picker--round" id="pk-closed"
         role="dialog" aria-modal="true" aria-labelledby="pk-closed-title" aria-hidden="true">
      <div class="kole-m-picker__header">
        <button class="kole-m-picker__btn" type="button">取消</button>
        <span class="kole-m-picker__title" id="pk-closed-title">选择城市</span>
        <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button">确定</button>
      </div>
      <div class="kole-m-picker__columns">
        <ul class="kole-m-picker__column" role="listbox" aria-label="城市">
          <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">上海</li>
          <li class="kole-m-picker__option" role="option" aria-selected="false">杭州</li>
        </ul>
      </div>
    </div>
  </div>
</section>

API

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

Props

名称类型默认值说明必传
mode'single' | 'multiple''single'变体 mode:single 单列滚轮,multiple 多列滚轮(规格 §43.3)N
roundbooleanfalse变体 round:靠内容一侧是否切圆角(规格 §43.3)N
openbooleanfalse状态 open:浮层是否展开(规格 §43.4)N
closeOnMaskbooleantrue遮罩点击是否关闭(规格 §43.5)N
valueArray<string>[]当前选中值,按列顺序排列(单列时长度 1);用于让选中项可见(规格 §43.6)N
columnsArray<{ label: string, options: Array<string | { label: string, value: string, disabled?: boolean }> }>[]列定义(宿主提供,本组件不发明选项数据源)(规格 §43.7)N
disabledbooleanfalse变体 disabled:整块置灰且不响应(规格 §43.4)N
titlestring''面板标题(空串时用默认「选择」)(规格 §43.2 picker__header)N

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

事件

名称参数说明
confirmvalue: Array<string>点击确定时触发,回传各列当前选中值(规格 §43.5)
close—点击取消或遮罩(closeOnMask=true 时)时触发(规格 §43.5)

插槽

名称说明
default自定义内容(追加在面板之前,常见用法是放触发器)

CSS 变量

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

名称默认值说明
--kole-m-picker-column-height200px单列可视高度(约 4.5 行)
--kole-m-picker-option-heightvar(--kole-m-touch-target)选项行高(≥44px 触控最小边长)

何时使用

  • 从一组有限且已知的选项里选出一项或几项(城市、分值、时间),是表单里下拉框在触屏上的形态
  • 可绑定到 Popup 的底部形态,但本组件自带遮罩与面板,不要在弹出层里再套一层
  • 遮罩点击关闭;closeOnMask=false 时不关闭
  • 选项行高不小于 44px,滚动容器 -webkit-overflow-scrolling: touch
  • 点击选项只改本列高亮,不改宿主的值;确认时才提交
  • 确定 / 取消按钮热区不小于 44px
  • 取消即返回:点取消或遮罩丢弃本次点选,宿主侧的值回到打开前的状态

交互与触控

  • 遮罩点击关闭;closeOnMask=false 时不关闭
  • 选项行高不小于 44px,滚动容器 -webkit-overflow-scrolling: touch
  • 点击选项只改本列高亮(同列其余项取消高亮),不改宿主的值;确认时才提交
  • 确定 / 取消按钮热区不小于 44px
  • 取消防返回:点取消或遮罩丢弃本次点选,宿主侧的值回到打开前的状态

无障碍

  • 浮层 role="dialog" + aria-modal="true",标题元素 id 由 aria-labelledby 指向
  • 每列 role="listbox" + aria-label,选项 role="option" + aria-selected
  • 遮罩 aria-hidden="true"(纯装饰,读屏不播报)
  • 选中值以文本呈现在标题里(不依赖视觉滚动位置),收起态写 aria-hidden="true"
  • 禁用项写 aria-disabled="true",读屏播报不可用

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

组件何时用它而不是本组件
弹出层Popup通用弹出层;本组件就是底部浮层形态,不要在弹出层里再套一层选择器
动作面板ActionSheet动作面板也是底部升起的选项列表,但面向「点一下即执行」,不区分确定 / 取消
日期选择器DatePicker同样是底部滚轮,但选项是年月日这类有历法语义的日期时才用它
级联选择器Cascader选项之间有层级联动(省 → 市 → 区)时用级联选择器,而不是自己拼多列
输入框Input选项数量不定或需要用户自由输入时用它,不要用选择器硬塞长列表

规格未定 / 禁止发明

类别条目
禁止发明选项数据源与联动规则(由宿主传入 columns,本组件不发明城市库或级联关系)
禁止发明滚轮惯性 / 吸附动画的物理参数
禁止发明搜索过滤与键盘输入定位(那是 Input / Search 的职责)
禁止发明多选(一次选多个值)——本组件是「多列各选一项」,不是「一列选多项」
规格未定面板最大高度是否应随列数增长(当前固定 max-height 一列 200px)
规格未定是否要支持「不选」的空值项
规格未定列数上限(当前实现不限制,但三列以上在 375px 宽度下每列会很窄)

结构(anatomy)

字段说明
mask遮罩,点击关闭(closeOnMask=false 时不关)
picker底部浮层面板,role=dialog + aria-modal
picker__header取消 / 标题 / 确定三格,标题元素由 aria-labelledby 指向
picker__columns列容器,single 一列、multiple 多列等分
picker__column单列,role=listbox + aria-label 说明这列在选什么,可滚动
picker__option单个选项,role=option + aria-selected,行高不小于 44px

变体维度与类名映射

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

维度取值对应类名 / 变量
modesingle / multiple
single .kole-m-picker--single
multiple .kole-m-picker--multiple
roundfalse / true
false (由数据驱动,无专属类)
true .kole-m-picker--round

代表变体

变体标签
mode=single · round=true单列滚轮(城市这类单值选择)
mode=multiple · round=true多列滚轮(时 / 分两列各选一项)
mode=single · round=false直角(不含圆角)
mode=single · round=true禁用(整块置灰且不响应)

用到的令牌

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

--kole-m-duration-slide --kole-m-ease-slide --kole-m-font-size-body --kole-m-font-size-label --kole-m-gutter --kole-m-safe-bottom --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-bg --kole-color-card-bg --kole-color-focus-ring --kole-color-mask --kole-color-table-header-bg --kole-color-text-body --kole-color-text-disabled --kole-color-text-title --kole-font-family --kole-radius-base --kole-radius-large --kole-space-4 --kole-space-8 --kole-m-picker-column-height --kole-m-picker-option-height

6 端源码

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

frameworks-mobile/Picker.css · 纯样式(CSS) · 182 行
frameworks-mobile/Picker.css
/* Kole UI Mobile · Picker 样式 — 对齐移动端规格 §43
   选择器:底部滚轮浮层,单列或多列联动;选项行高 ≥44px;选中项品牌色高亮。
   选中值的真源是 aria-selected 与文本(不依赖视觉滚动位置)。
   演示页触发器(非组件交付样式)见本文件末尾。 */

.kole-m-picker__mask {
  position: fixed;
  inset: 0;
  z-index: 2000;
  background: var(--kole-color-mask);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--kole-m-duration-slide) var(--kole-m-ease-slide);
}

.kole-m-picker__mask.is-open {
  opacity: 1;
  pointer-events: auto;
}

.kole-m-picker {
  /* 组件级变量:业务侧可在容器上覆盖 */
  --kole-m-picker-column-height: 200px;   /* 单列可视高度(约 4.5 行) */
  --kole-m-picker-option-height: var(--kole-m-touch-target); /* 选项行高(≥44px 触控最小边长) */
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2001;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  width: 100%;
  padding-bottom: var(--kole-m-safe-bottom);
  background: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-family: var(--kole-font-family);
  font-size: var(--kole-m-font-size-body);
  transform: translateY(100%);
  transition: transform var(--kole-m-duration-slide) var(--kole-m-ease-slide);
}

.kole-m-picker.is-open { transform: translateY(0); }

/* 变体 mode=single:只有一列,占满整个宽度 */
.kole-m-picker--single .kole-m-picker__column {
  flex: 1 1 100%;
}

/* 变体 mode=multiple:多列等分(联动时后一列随前一列重建) */
.kole-m-picker--multiple .kole-m-picker__column {
  flex: 1 1 0;
}

/* 变体 round=true:靠内容一侧切圆角 */
.kole-m-picker--round {
  border-start-start-radius: var(--kole-radius-large);
  border-start-end-radius: var(--kole-radius-large);
}

/* 状态 disabled:整块置灰且不响应(点选与确认都被拦下) */
.kole-m-picker.is-disabled .kole-m-picker__btn { color: var(--kole-color-text-disabled); }
.kole-m-picker.is-disabled .kole-m-picker__option { color: var(--kole-color-text-disabled); }

.kole-m-picker__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--kole-space-8);
  padding: var(--kole-space-8) var(--kole-m-gutter);
  border-bottom: 1px solid var(--kole-color-border);
  font-size: var(--kole-m-font-size-body);
}

.kole-m-picker__title {
  color: var(--kole-color-text-title);
  font-weight: 500;
}

.kole-m-picker__btn {
  box-sizing: border-box;
  min-height: var(--kole-m-touch-target);
  padding: 0 var(--kole-space-8);
  border: 0;
  border-radius: var(--kole-radius-base);
  background: none;
  color: var(--kole-color-brand);
  font-family: inherit;
  font-size: var(--kole-m-font-size-label);
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
}

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

.kole-m-picker__btn:active { background: var(--kole-color-brand-bg); }

.kole-m-picker__btn--confirm { font-weight: 500; }

.kole-m-picker__columns {
  display: flex;
  align-items: stretch;
  gap: 1px;
  padding: 0 0 var(--kole-space-8);
  background: var(--kole-color-card-bg);
}

.kole-m-picker__column {
  flex: 1 1 0;
  min-width: 0;
  max-height: var(--kole-m-picker-column-height);
  margin: 0;
  padding: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  list-style: none;
}

.kole-m-picker__option {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--kole-m-picker-option-height);
  padding: 0 var(--kole-space-4);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-label);
  line-height: 1.3;
  text-align: center;
  cursor: pointer;
  touch-action: manipulation;
}

.kole-m-picker__option:active { background: var(--kole-color-table-header-bg); }

/* 状态 selected:当前选中项(品牌色高亮,与 aria-selected 同步) */
.kole-m-picker__option.is-selected {
  color: var(--kole-color-brand);
  font-weight: 500;
}

.kole-m-picker__option[aria-selected="true"] {
  color: var(--kole-color-brand);
  font-weight: 500;
}

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

.kole-m-picker__option.is-disabled:active { background: none; }

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

/* 演示页触发器(非组件交付样式) */
.kole-m-picker__trigger {
  min-height: var(--kole-m-touch-target);
  padding: 0 var(--kole-m-gutter);
  border: 1px solid var(--kole-color-border);
  border-radius: var(--kole-radius-base);
  background: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-family: inherit;
  font-size: var(--kole-m-font-size-label);
  cursor: pointer;
  touch-action: manipulation;
}

.kole-m-picker__trigger:focus-visible {
  outline: 2px solid var(--kole-color-focus-ring);
  outline-offset: 2px;
}
frameworks-mobile/Picker.html · H5 原生(无框架) · 225 行
frameworks-mobile/Picker.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 · Picker(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Picker.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); }
  /* 展示框:transform 建立包含块,遮罩与浮层限定在框内(生产环境落在视口上) */
  .demo-frame { position: relative; max-width: 375px; margin: 0 auto; height: 300px;
    overflow: hidden; transform: translateZ(0); background: var(--kole-color-page-bg);
    border-block: 1px solid var(--kole-color-border); }
  .demo-frame__body { padding: var(--kole-m-gutter); font-size: var(--kole-m-font-size-label);
    color: var(--kole-color-text-secondary); line-height: 1.7; }
  .demo-value { color: var(--kole-color-text-body); font-weight: 500; }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="single">
    <p class="demo-label">单列滚轮(mode=single:常用城市;点选项即高亮,确认前不改宿主的值)</p>
    <div class="demo-frame" data-assert="picker-single">
      <div class="demo-frame__body">
        <p>已选城市:<span class="demo-value" id="pk-single-value">上海</span></p>
      </div>
      <div class="kole-m-picker__mask is-open" aria-hidden="true"></div>
      <div class="kole-m-picker kole-m-picker--single kole-m-picker--round is-open" id="pk-single"
           role="dialog" aria-modal="true" aria-labelledby="pk-single-title">
        <div class="kole-m-picker__header">
          <button class="kole-m-picker__btn" type="button" id="pk-single-cancel"
                  data-behavior="click-sets-attr:#pk-single|data-cancelled|true">取消</button>
          <span class="kole-m-picker__title" id="pk-single-title">选择城市</span>
          <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button" id="pk-single-confirm"
                  data-behavior="click-sets-attr:#pk-single|data-confirmed|true">确定</button>
        </div>
        <div class="kole-m-picker__columns">
          <ul class="kole-m-picker__column" role="listbox" aria-label="城市">
            <li class="kole-m-picker__option" role="option" aria-selected="false">北京</li>
            <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">上海</li>
            <li class="kole-m-picker__option" role="option" aria-selected="false" id="pk-single-opt-hz"
                data-behavior="click-sets-attr:#pk-single-opt-hz|aria-selected|true">杭州</li>
            <li class="kole-m-picker__option" role="option" aria-selected="false">成都</li>
            <li class="kole-m-picker__option" role="option" aria-selected="false">广州</li>
          </ul>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="multiple">
    <p class="demo-label">多列滚轮(mode=multiple:时 / 分两列,各列独立选中)</p>
    <div class="demo-frame" data-assert="picker-multiple">
      <div class="demo-frame__body">
        <p>已选时间:<span class="demo-value" id="pk-multiple-value">09:30</span></p>
      </div>
      <div class="kole-m-picker__mask is-open" aria-hidden="true"></div>
      <div class="kole-m-picker kole-m-picker--multiple kole-m-picker--round is-open" id="pk-multiple"
           role="dialog" aria-modal="true" aria-labelledby="pk-multiple-title">
        <div class="kole-m-picker__header">
          <button class="kole-m-picker__btn" type="button">取消</button>
          <span class="kole-m-picker__title" id="pk-multiple-title">选择时间</span>
          <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button">确定</button>
        </div>
        <div class="kole-m-picker__columns">
          <ul class="kole-m-picker__column" role="listbox" aria-label="时">
            <li class="kole-m-picker__option" role="option" aria-selected="false">08</li>
            <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">09</li>
            <li class="kole-m-picker__option" role="option" aria-selected="false">10</li>
          </ul>
          <ul class="kole-m-picker__column" role="listbox" aria-label="分">
            <li class="kole-m-picker__option" role="option" aria-selected="false">00</li>
            <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">30</li>
            <li class="kole-m-picker__option" role="option" aria-selected="false" id="pk-multiple-opt-45"
                data-behavior="click-sets-attr:#pk-multiple-opt-45|aria-selected|true">45</li>
          </ul>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="selected">
    <p class="demo-label">选中态与禁用项(选中 = is-selected + aria-selected 双通道;禁用项置灰且不响应)</p>
    <div class="demo-frame" data-assert="picker-selected">
      <div class="kole-m-picker__mask is-open" aria-hidden="true"></div>
      <div class="kole-m-picker kole-m-picker--single kole-m-picker--round is-open"
           role="dialog" aria-modal="true" aria-labelledby="pk-selected-title">
        <div class="kole-m-picker__header">
          <button class="kole-m-picker__btn" type="button">取消</button>
          <span class="kole-m-picker__title" id="pk-selected-title">选择面额</span>
          <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button">确定</button>
        </div>
        <div class="kole-m-picker__columns">
          <ul class="kole-m-picker__column" role="listbox" aria-label="面额">
            <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">¥ 50</li>
            <li class="kole-m-picker__option" role="option" aria-selected="false">¥ 100</li>
            <li class="kole-m-picker__option is-disabled" role="option" aria-selected="false" aria-disabled="true">¥ 200(已售罄)</li>
          </ul>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="closed">
    <p class="demo-label">收起态(status=closed:遮罩 pointer-events: none,点触发器才拉起浮层)</p>
    <div class="demo-frame" data-assert="picker-closed">
      <div class="demo-frame__body">
        <p>收起时页面可正常滚动与点击。</p>
        <button class="kole-m-picker__trigger" type="button" id="pk-closed-trigger"
                data-behavior="click-toggles-class:#pk-closed|is-open">选择城市</button>
      </div>
      <div class="kole-m-picker__mask" id="pk-closed-mask" aria-hidden="true"></div>
      <div class="kole-m-picker kole-m-picker--single kole-m-picker--round" id="pk-closed"
           role="dialog" aria-modal="true" aria-labelledby="pk-closed-title" aria-hidden="true">
        <div class="kole-m-picker__header">
          <button class="kole-m-picker__btn" type="button">取消</button>
          <span class="kole-m-picker__title" id="pk-closed-title">选择城市</span>
          <button class="kole-m-picker__btn kole-m-picker__btn--confirm" type="button">确定</button>
        </div>
        <div class="kole-m-picker__columns">
          <ul class="kole-m-picker__column" role="listbox" aria-label="城市">
            <li class="kole-m-picker__option is-selected" role="option" aria-selected="true">上海</li>
            <li class="kole-m-picker__option" role="option" aria-selected="false">杭州</li>
          </ul>
        </div>
      </div>
    </div>
  </section>
</div>
<script>
  /* 演示页交互:选项选中只改本地高亮(is-selected + aria-selected 同步),
     确认 / 取消只打标记 —— 真实业务里由宿主读这两个事件并决定是否采纳。
     每块各自独立:状态都落在本块的 DOM 上,不依赖其它块。 */
  (function () {
    function select(picker, option) {
      var col = option.closest ? option.closest('.kole-m-picker__column') : null;
      if (!col) return;
      Array.prototype.forEach.call(col.querySelectorAll('.kole-m-picker__option'), function (li) {
        li.classList.remove('is-selected');
        li.setAttribute('aria-selected', 'false');
      });
      option.classList.add('is-selected');
      option.setAttribute('aria-selected', 'true');
      /* 汇总当前各列选中文本,写回宿主展示位与根节点的 data-value(供宿主持久化) */
      var picked = [];
      Array.prototype.forEach.call(picker.querySelectorAll('.kole-m-picker__column'), function (c) {
        var on = c.querySelector('.kole-m-picker__option.is-selected');
        if (on) picked.push((on.textContent || '').trim());
      });
      picker.setAttribute('data-value', picked.join(' '));
      var out = document.getElementById(picker.id + '-value');
      if (out) out.textContent = picked.join(' ');
    }

    Array.prototype.forEach.call(document.querySelectorAll('.kole-m-picker'), function (picker) {
      picker.addEventListener('click', function (e) {
        var option = e.target.closest ? e.target.closest('.kole-m-picker__option') : null;
        if (!option || !picker.contains(option)) return;
        if (option.getAttribute('aria-disabled') === 'true' || option.classList.contains('is-disabled')) return;
        select(picker, option);
      });
    });

    /* 取消 / 确定:只打标记(真实业务里由宿主读这两个事件,决定是否采纳本次点选) */
    var cancel = document.getElementById('pk-single-cancel');
    var confirm = document.getElementById('pk-single-confirm');
    var panel = document.getElementById('pk-single');
    if (cancel && panel) {
      cancel.addEventListener('click', function () { panel.setAttribute('data-cancelled', 'true'); });
    }
    if (confirm && panel) {
      confirm.addEventListener('click', function () { panel.setAttribute('data-confirmed', 'true'); });
    }

    /* 收起态触发器:点一下把浮层与遮罩一起拉开(真实业务里由宿主控制 open) */
    var trigger = document.getElementById('pk-closed-trigger');
    var closed = document.getElementById('pk-closed');
    var closedMask = document.getElementById('pk-closed-mask');
    if (trigger && closed) {
      trigger.addEventListener('click', function () {
        var on = closed.classList.toggle('is-open');
        closed.setAttribute('aria-hidden', on ? 'false' : 'true');
        if (closedMask) closedMask.classList.toggle('is-open', on);
      });
    }

    /* 根节点初始 data-value:与 HTML 里的 is-selected 保持一致 */
    Array.prototype.forEach.call(document.querySelectorAll('.kole-m-picker'), function (root) {
      var picked = [];
      Array.prototype.forEach.call(root.querySelectorAll('.kole-m-picker__column'), function (c) {
        var on = c.querySelector('.kole-m-picker__option.is-selected');
        if (on) picked.push((on.textContent || '').trim());
      });
      root.setAttribute('data-value', picked.join(' '));
    });
  })();
</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/Picker.jsx · React · 145 行
frameworks-mobile/Picker.jsx
import React, { useRef, useState } from 'react';
import './Picker.css';

/* 选择器(移动端)— 规格 §43。
   底部浮层 = 装饰性遮罩(aria-hidden)+ 面板;遮罩点击关闭,closeOnMask=false 时不关(规格 §43.5)。
   面板 role="dialog" + aria-modal="true",标题元素 id 由 aria-labelledby 指向(规格 §43.6)。
   每列 role="listbox"、选项 role="option" + aria-selected;选中项的 is-selected 与 aria-selected 同步。
   mode=single 一列、mode=multiple 多列(各列独立选中,联动与否由宿主给定的选项决定)。
   本端不发明选择事件(契约只有 confirm / close):点选只改本地高亮,确认时由宿主读 confirm 后的语义。 */

/* 面板标题元素 id:同页多实例不撞号(不依赖 React 版本特性,模块级自增即可) */
let seq = 0;

/* 选项可能是字符串,也可能是 { label, value, disabled } 对象(规格 §43.7 只允许宿主提供) */
function labelOf(option) {
  if (option === null || option === undefined) return '';
  return typeof option === 'object' ? String(option.label === undefined ? '' : option.label) : String(option);
}

function disabledOf(option) {
  return !!(option && typeof option === 'object' && option.disabled);
}

/* 选中判据:选项是对象时比 value,否则比文本(与演示页同一口径) */
function sameValue(option, picked) {
  if (picked === undefined || picked === null || picked === '') return false;
  var value = option && typeof option === 'object' ? option.value : option;
  return String(value) === String(picked);
}

/* mode=single → 取 columns 第一列;mode=multiple → 全部列(规格 §43.3) */
function columnsOf(mode, columns) {
  var list = Array.isArray(columns) ? columns : [];
  return mode === 'single' ? list.slice(0, 1) : list;
}

export default function Picker({
  mode = 'single',
  round = false,
  open = false,
  closeOnMask = true,
  value = [],
  columns = [],
  disabled = false,
  title = '',
  onConfirm,
  onClose,
  children = null,
}) {
  const idRef = useRef(null);
  if (idRef.current === null) {
    seq += 1;
    idRef.current = 'kole-m-picker-title-' + seq;
  }
  const titleId = idRef.current;

  /* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
  const [draft, setDraft] = useState(null);
  const picked = draft || (Array.isArray(value) ? value : []);
  const cols = columnsOf(mode, columns);

  const panelLabel = title || '选择';
  const label = panelLabel + (picked.length ? ':' + picked.join(' ') : '');

  function select(colIndex, option) {
    if (disabled || disabledOf(option)) return;
    const next = picked.slice();
    next[colIndex] = String(option && typeof option === 'object' ? option.value : option);
    setDraft(next);
  }

  return (
    <>
      {children}
      <div
        className={'kole-m-picker__mask' + (open ? ' is-open' : '')}
        aria-hidden="true"
        onClick={() => {
          if (closeOnMask && onClose) onClose();
        }}
      />
      <div
        className={
          'kole-m-picker kole-m-picker--' + (mode === 'multiple' ? 'multiple' : 'single') +
          (round ? ' kole-m-picker--round' : '') +
          (open ? ' is-open' : '') +
          (disabled ? ' is-disabled' : '')
        }
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-hidden={open ? undefined : 'true'}
      >
        <div className="kole-m-picker__header">
          <button
            className="kole-m-picker__btn"
            type="button"
            onClick={() => {
              if (onClose) onClose();
            }}
          >
            取消
          </button>
          <span className="kole-m-picker__title" id={titleId}>
            {label}
          </span>
          <button
            className="kole-m-picker__btn kole-m-picker__btn--confirm"
            type="button"
            onClick={() => {
              if (!disabled && onConfirm) onConfirm(picked);
            }}
          >
            确定
          </button>
        </div>
        <div className="kole-m-picker__columns">
          {cols.map((col, ci) => (
            <ul className="kole-m-picker__column" role="listbox" aria-label={col.label} key={col.key || ci}>
              {(col.options || []).map((option, i) => {
                const selected = sameValue(option, picked[ci]);
                const off = disabled || disabledOf(option);
                return (
                  <li
                    className={
                      'kole-m-picker__option' + (selected ? ' is-selected' : '') + (off ? ' is-disabled' : '')
                    }
                    role="option"
                    aria-selected={selected ? 'true' : 'false'}
                    aria-disabled={off ? 'true' : undefined}
                    key={labelOf(option) + i}
                    onClick={() => select(ci, option)}
                  >
                    {labelOf(option)}
                  </li>
                );
              })}
            </ul>
          ))}
        </div>
      </div>
    </>
  );
}
frameworks-mobile/Picker.vue2.vue · Vue 2 · 159 行
frameworks-mobile/Picker.vue2.vue
<template>
  <div>
    <slot></slot>
    <div
      class="kole-m-picker__mask"
      :class="{ 'is-open': open }"
      aria-hidden="true"
      @click="onMaskClick"
    ></div>
    <div
      class="kole-m-picker"
      :class="panelClass"
      role="dialog"
      aria-modal="true"
      :aria-labelledby="titleId"
      :aria-hidden="open ? null : 'true'"
    >
      <div class="kole-m-picker__header">
        <button class="kole-m-picker__btn" type="button" @click="onCancelClick">取消</button>
        <span class="kole-m-picker__title" :id="titleId">{{ label }}</span>
        <button
          class="kole-m-picker__btn kole-m-picker__btn--confirm"
          type="button"
          @click="onConfirmClick"
        >确定</button>
      </div>
      <div class="kole-m-picker__columns">
        <ul
          v-for="(col, ci) in cols"
          :key="col.key || ci"
          class="kole-m-picker__column"
          role="listbox"
          :aria-label="col.label"
        >
          <li
            v-for="(option, i) in col.options || []"
            :key="labelOf(option) + '-' + i"
            class="kole-m-picker__option"
            :class="{ 'is-selected': isSelected(ci, option), 'is-disabled': isOff(option) }"
            role="option"
            :aria-selected="isSelected(ci, option) ? 'true' : 'false'"
            :aria-disabled="isOff(option) ? 'true' : null"
            @click="select(ci, option)"
          >{{ labelOf(option) }}</li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
/* 选择器(移动端)— 规格 §43。
   底部浮层 = 装饰性遮罩(aria-hidden)+ 面板;遮罩点击关闭,closeOnMask=false 时不关(规格 §43.5)。
   面板 role="dialog" + aria-modal="true",标题元素 id 由 aria-labelledby 指向(规格 §43.6)。
   每列 role="listbox"、选项 role="option" + aria-selected;is-selected 与 aria-selected 同步。
   mode=single 一列、mode=multiple 多列(各列独立选中,是否联动由宿主给的选项决定)。
   本端不发明选择事件(契约只有 confirm / close):点选只改本地高亮。 */

/* 选项可能是字符串,也可能是 { label, value, disabled } 对象(规格 §43.7 只允许宿主提供) */
function labelOf(option) {
  if (option === null || option === undefined) return '';
  return typeof option === 'object' ? String(option.label === undefined ? '' : option.label) : String(option);
}

function disabledOf(option) {
  return !!(option && typeof option === 'object' && option.disabled);
}

/* 选中判据:选项是对象时比 value,否则比文本(与演示页同一口径) */
function sameValue(option, picked) {
  if (picked === undefined || picked === null || picked === '') return false;
  var value = option && typeof option === 'object' ? option.value : option;
  return String(value) === String(picked);
}

/* mode=single → 取 columns 第一列;mode=multiple → 全部列(规格 §43.3) */
function columnsOf(mode, columns) {
  var list = Array.isArray(columns) ? columns : [];
  return mode === 'single' ? list.slice(0, 1) : list;
}

/* 面板标题元素 id 的自增种子:同页多实例不撞号(不依赖内部实例字段) */
var titleSeed = 0;

export default {
  name: 'KoleMPicker',
  props: {
    mode: { type: String, default: 'single' },
    round: { type: Boolean, default: false },
    open: { type: Boolean, default: false },
    closeOnMask: { type: Boolean, default: true },
    value: { type: Array, default: function () { return []; } },
    columns: { type: Array, default: function () { return []; } },
    disabled: { type: Boolean, default: false },
    title: { type: String, default: '' }
  },
  data: function () {
    /* 每个实例占一个号,标题 id 因此唯一(aria-labelledby 指向它) */
    titleSeed += 1;
    return {
      instanceId: titleSeed,
      /* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
      draft: null
    };
  },
  computed: {
    titleId: function () {
      return 'kole-m-picker-title-' + this.instanceId;
    },
    picked: function () {
      return this.draft || (Array.isArray(this.value) ? this.value : []);
    },
    cols: function () {
      return columnsOf(this.mode, this.columns);
    },
    label: function () {
      var t = this.title || '选择';
      return this.picked.length ? t + ':' + this.picked.join(' ') : t;
    },
    panelClass: function () {
      return [
        'kole-m-picker--' + (this.mode === 'multiple' ? 'multiple' : 'single'),
        this.round ? 'kole-m-picker--round' : '',
        this.open ? 'is-open' : '',
        this.disabled ? 'is-disabled' : ''
      ].filter(Boolean);
    }
  },
  methods: {
    labelOf: labelOf,
    isSelected: function (ci, option) {
      return sameValue(option, this.picked[ci]);
    },
    isOff: function (option) {
      return this.disabled || disabledOf(option);
    },
    select: function (ci, option) {
      if (this.isOff(option)) return;
      var next = this.picked.slice();
      next[ci] = String(option && typeof option === 'object' ? option.value : option);
      this.draft = next;
    },
    onMaskClick: function () {
      if (!this.closeOnMask) return;
      this.$emit('close');
    },
    onCancelClick: function () {
      this.$emit('close');
    },
    onConfirmClick: function () {
      if (this.disabled) return;
      this.$emit('confirm', this.picked);
    }
  }
};
</script>

<style src="./Picker.css"></style>
frameworks-mobile/Picker.vue3.vue · Vue 3 · 144 行
frameworks-mobile/Picker.vue3.vue
<template>
  <div>
    <slot></slot>
    <div
      class="kole-m-picker__mask"
      :class="{ 'is-open': open }"
      aria-hidden="true"
      @click="onMaskClick"
    ></div>
    <div
      class="kole-m-picker"
      :class="panelClass"
      role="dialog"
      aria-modal="true"
      :aria-labelledby="titleId"
      :aria-hidden="open ? null : 'true'"
    >
      <div class="kole-m-picker__header">
        <button class="kole-m-picker__btn" type="button" @click="onCancelClick">取消</button>
        <span class="kole-m-picker__title" :id="titleId">{{ label }}</span>
        <button
          class="kole-m-picker__btn kole-m-picker__btn--confirm"
          type="button"
          @click="onConfirmClick"
        >确定</button>
      </div>
      <div class="kole-m-picker__columns">
        <ul
          v-for="(col, ci) in cols"
          :key="col.key || ci"
          class="kole-m-picker__column"
          role="listbox"
          :aria-label="col.label"
        >
          <li
            v-for="(option, i) in col.options || []"
            :key="labelOf(option) + '-' + i"
            class="kole-m-picker__option"
            :class="{ 'is-selected': isSelected(ci, option), 'is-disabled': isOff(option) }"
            role="option"
            :aria-selected="isSelected(ci, option) ? 'true' : 'false'"
            :aria-disabled="isOff(option) ? 'true' : null"
            @click="select(ci, option)"
          >{{ labelOf(option) }}</li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script setup>
/* 选择器(移动端)— 规格 §43。
   底部浮层 = 装饰性遮罩(aria-hidden)+ 面板;遮罩点击关闭,closeOnMask=false 时不关(规格 §43.5)。
   面板 role="dialog" + aria-modal="true",标题元素 id 由 aria-labelledby 指向(规格 §43.6)。
   每列 role="listbox"、选项 role="option" + aria-selected;is-selected 与 aria-selected 同步。
   mode=single 一列、mode=multiple 多列(各列独立选中,是否联动由宿主给的选项决定)。
   本端不发明选择事件(契约只有 confirm / close):点选只改本地高亮。 */
import { computed, getCurrentInstance, ref } from 'vue';

const props = defineProps({
  mode: { type: String, default: 'single' },
  round: { type: Boolean, default: false },
  open: { type: Boolean, default: false },
  closeOnMask: { type: Boolean, default: true },
  value: { type: Array, default: () => [] },
  columns: { type: Array, default: () => [] },
  disabled: { type: Boolean, default: false },
  title: { type: String, default: '' }
});
const emit = defineEmits(['confirm', 'close']);

/* 面板标题元素 id:取当前实例 uid,保证同页多实例不撞号(不依赖 Vue 3.5 才有的 useId) */
const titleId = 'kole-m-picker-title-' + getCurrentInstance().uid;

/* 选项可能是字符串,也可能是 { label, value, disabled } 对象(规格 §43.7 只允许宿主提供) */
function labelOf(option) {
  if (option === null || option === undefined) return '';
  return typeof option === 'object' ? String(option.label === undefined ? '' : option.label) : String(option);
}

function disabledOf(option) {
  return !!(option && typeof option === 'object' && option.disabled);
}

/* 选中判据:选项是对象时比 value,否则比文本(与演示页同一口径) */
function sameValue(option, picked) {
  if (picked === undefined || picked === null || picked === '') return false;
  const value = option && typeof option === 'object' ? option.value : option;
  return String(value) === String(picked);
}

/* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
const draft = ref(null);
const picked = computed(() => draft.value || (Array.isArray(props.value) ? props.value : []));

/* mode=single → 取 columns 第一列;mode=multiple → 全部列(规格 §43.3) */
const cols = computed(() => {
  const list = Array.isArray(props.columns) ? props.columns : [];
  return props.mode === 'single' ? list.slice(0, 1) : list;
});
const label = computed(() => {
  const t = props.title || '选择';
  return picked.value.length ? t + ':' + picked.value.join(' ') : t;
});

const panelClass = computed(() => [
  'kole-m-picker--' + (props.mode === 'multiple' ? 'multiple' : 'single'),
  props.round ? 'kole-m-picker--round' : '',
  props.open ? 'is-open' : '',
  props.disabled ? 'is-disabled' : ''
].filter(Boolean));

function isSelected(ci, option) {
  return sameValue(option, picked.value[ci]);
}

function isOff(option) {
  return props.disabled || disabledOf(option);
}

function select(ci, option) {
  if (isOff(option)) return;
  const next = picked.value.slice();
  next[ci] = String(option && typeof option === 'object' ? option.value : option);
  draft.value = next;
}

function onMaskClick() {
  if (!props.closeOnMask) return;
  emit('close');
}

function onCancelClick() {
  emit('close');
}

function onConfirmClick() {
  if (props.disabled) return;
  emit('confirm', picked.value);
}
</script>

<style src="./Picker.css"></style>
frameworks-mobile/Picker.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 264 行
frameworks-mobile/Picker.uniapp.vue
<template>
  <view>
    <slot></slot>
    <view
      class="kole-m-picker__mask"
      :class="{ 'is-open': open }"
      aria-hidden="true"
      @tap="onMaskTap"
      @touchmove.stop.prevent
    ></view>
    <view
      class="kole-m-picker"
      :class="panelClass"
      :role="'dialog'"
      :aria-modal="'true'"
      :aria-labelledby="titleId"
      :aria-hidden="open ? 'false' : 'true'"
    >
      <view class="kole-m-picker__header">
        <view class="kole-m-picker__btn" role="button" @tap="onCancelTap">
          <text>取消</text>
        </view>
        <text class="kole-m-picker__title" :id="titleId">{{ label }}</text>
        <view
          class="kole-m-picker__btn kole-m-picker__btn--confirm"
          role="button"
          @tap="onConfirmTap"
        >
          <text>确定</text>
        </view>
      </view>
      <view class="kole-m-picker__columns">
        <scroll-view
          v-for="(col, ci) in cols"
          :key="col.key || ci"
          class="kole-m-picker__column"
          scroll-y
          :role="'listbox'"
          :aria-label="col.label"
        >
          <view
            v-for="(option, i) in col.options || []"
            :key="labelOf(option) + '-' + i"
            class="kole-m-picker__option"
            :class="isSelected(ci, option) ? 'is-selected' : (isOff(option) ? 'is-disabled' : '')"
            :role="'option'"
            :aria-selected="isSelected(ci, option) ? 'true' : 'false'"
            :aria-disabled="isOff(option) ? 'true' : 'false'"
            @tap="select(ci, option)"
          >
            <text>{{ labelOf(option) }}</text>
          </view>
        </scroll-view>
      </view>
    </view>
  </view>
</template>

<script setup>
/* uni-app 端 · 选择器(移动端)— 规格 §43
   跨端差异:用 view / text;每列用 scroll-view(scroll-y)代替 ul / li,滚动由小程序/App 原生接管,
   role / aria-* 仍按规格 §43.6 保留(用 :role / :aria-* 绑定,H5 产物里生效)。
   点击一律 @tap;遮罩用 @touchmove.stop.prevent 阻止背景滚动穿透。
   面板标题元素 id 用模块级自增序号拼出,同页多实例不撞号;aria-labelledby 指向它。
   本端不发明选择事件(契约只有 confirm / close):点选只改本地高亮。
   尺寸用 rpx(88rpx = 375pt 下的 44px 触控最小边长)。 */
import { computed, ref } from 'vue';

/* 面板标题元素 id 的自增种子:同页多实例不撞号(uni 端也不依赖内部实例字段) */
let titleSeed = 0;

const props = defineProps({
  mode: { type: String, default: 'single' },
  round: { type: Boolean, default: false },
  open: { type: Boolean, default: false },
  closeOnMask: { type: Boolean, default: true },
  value: { type: Array, default: () => [] },
  columns: { type: Array, default: () => [] },
  disabled: { type: Boolean, default: false },
  title: { type: String, default: '' }
});
const emit = defineEmits(['confirm', 'close']);

/* 面板标题元素 id:组件初始化时占一个号,保证同页多实例不撞号 */
titleSeed += 1;
const titleId = 'kole-m-picker-title-' + titleSeed;

/* 选项可能是字符串,也可能是 { label, value, disabled } 对象(规格 §43.7 只允许宿主提供) */
function labelOf(option) {
  if (option === null || option === undefined) return '';
  return typeof option === 'object' ? String(option.label === undefined ? '' : option.label) : String(option);
}

function disabledOf(option) {
  return !!(option && typeof option === 'object' && option.disabled);
}

/* 选中判据:选项是对象时比 value,否则比文本(与演示页同一口径) */
function sameValue(option, picked) {
  if (picked === undefined || picked === null || picked === '') return false;
  const value = option && typeof option === 'object' ? option.value : option;
  return String(value) === String(picked);
}

/* null = 跟随 value 属性;点选后本地记账,确认前不改宿主的值 */
const draft = ref(null);
const picked = computed(() => draft.value || (Array.isArray(props.value) ? props.value : []));

/* mode=single → 取 columns 第一列;mode=multiple → 全部列(规格 §43.3) */
const cols = computed(() => {
  const list = Array.isArray(props.columns) ? props.columns : [];
  return props.mode === 'single' ? list.slice(0, 1) : list;
});

const label = computed(() => {
  const t = props.title || '选择';
  return picked.value.length ? t + ':' + picked.value.join(' ') : t;
});

const panelClass = computed(() => [
  'kole-m-picker--' + (props.mode === 'multiple' ? 'multiple' : 'single'),
  props.round ? 'kole-m-picker--round' : '',
  props.open ? 'is-open' : ''
].filter(Boolean));

function isSelected(ci, option) {
  return sameValue(option, picked.value[ci]);
}

function isOff(option) {
  return props.disabled || disabledOf(option);
}

function select(ci, option) {
  if (isOff(option)) return;
  const next = picked.value.slice();
  next[ci] = String(option && typeof option === 'object' ? option.value : option);
  draft.value = next;
}

function onMaskTap() {
  if (!props.closeOnMask) return;
  emit('close');
}

function onCancelTap() {
  emit('close');
}

function onConfirmTap() {
  if (props.disabled) return;
  emit('confirm', picked.value);
}
</script>

<style>
.kole-m-picker {
  --kole-m-picker-column-height: 400rpx;
  --kole-m-picker-option-height: 88rpx;
  --kole-m-touch-target: 88rpx;
  --kole-m-action-height: 112rpx;
  --kole-m-font-size-body: 32rpx;
  --kole-m-font-size-label: 28rpx;
  --kole-m-gutter: 32rpx;
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2001;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  width: 100%;
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-body);
}

.kole-m-picker__mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2000;
  background-color: var(--kole-color-mask);
  opacity: 0;
}

.kole-m-picker__mask.is-open { opacity: 1; }

/* 变体 mode=single:只有一列,占满整个宽度 */
.kole-m-picker--single .kole-m-picker__column { flex: 1; }

/* 变体 mode=multiple:多列等分(联动时后一列随前一列重建) */
.kole-m-picker--multiple .kole-m-picker__column { flex: 1; }

/* 变体 round=true:靠内容一侧切圆角 */
.kole-m-picker--round {
  border-top-left-radius: 16rpx;
  border-top-right-radius: 16rpx;
}

.kole-m-picker__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16rpx var(--kole-m-gutter);
  border-bottom: 1rpx solid var(--kole-color-border);
  font-size: var(--kole-m-font-size-body);
}

.kole-m-picker__title {
  color: var(--kole-color-text-title);
  font-size: var(--kole-m-font-size-body);
}

.kole-m-picker__btn {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-height: var(--kole-m-touch-target);
  padding: 0 16rpx;
  border-radius: 8rpx;
  color: var(--kole-color-brand);
  font-size: var(--kole-m-font-size-label);
}

.kole-m-picker__btn--confirm { font-weight: 500; }

.kole-m-picker__columns {
  display: flex;
  align-items: stretch;
}

.kole-m-picker__column {
  min-width: 0;
  max-height: var(--kole-m-picker-column-height);
}

.kole-m-picker__option {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--kole-m-picker-option-height);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-label);
  text-align: center;
}

/* 状态 selected:当前选中项(品牌色高亮,与 aria-selected 同步) */
.kole-m-picker__option.is-selected {
  color: var(--kole-color-brand);
  font-weight: 500;
}

/* 状态 disabled:选项不可选(置灰) */
.kole-m-picker__option.is-disabled { color: var(--kole-color-text-disabled); }
</style>

测试与回归

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

断言 19 条 · 全部通过 报告 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-picker.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "43 · 选择器 Picker",
  "confidence": "high",
  "slug": "mobile-picker",
  "name": "选择器 Picker",
  "semanticTypeCandidates": [
    "picker",
    "select",
    "wheel"
  ],
  "variantDimensions": [
    {
      "name": "mode",
      "values": [
        "single",
        "multiple"
      ]
    },
    {
      "name": "round",
      "values": [
        "false",
        "true"
      ]
    }
  ],
  "representativeVariants": [
    {
      "mode": "single",
      "round": "true",
      "label": "单列滚轮(城市这类单值选择)"
    },
    {
      "mode": "multiple",
      "round": "true",
      "label": "多列滚轮(时 / 分两列各选一项)"
    },
    {
      "mode": "single",
      "round": "false",
      "label": "直角(不含圆角)"
    },
    {
      "mode": "single",
      "round": "true",
      "label": "禁用(整块置灰且不响应)"
    }
  ],
  "anatomy": {
    "mask": "遮罩,点击关闭(closeOnMask=false 时不关)",
    "picker": "底部浮层面板,role=dialog + aria-modal",
    "picker__header": "取消 / 标题 / 确定三格,标题元素由 aria-labelledby 指向",
    "picker__columns": "列容器,single 一列、multiple 多列等分",
    "picker__column": "单列,role=listbox + aria-label 说明这列在选什么,可滚动",
    "picker__option": "单个选项,role=option + aria-selected,行高不小于 44px"
  },
  "structurePatterns": {
    "mode": "single(单列滚轮)/ multiple(多列滚轮,各列独立选中)",
    "round": "false(直角)/ true(靠内容一侧切圆角)",
    "disabled": "false(可选)/ true(整块置灰且不响应)"
  },
  "usageHints": [
    "从一组有限且已知的选项里选出一项或几项(城市、分值、时间),是表单里下拉框在触屏上的形态",
    "可绑定到 Popup 的底部形态,但本组件自带遮罩与面板,不要在弹出层里再套一层",
    "遮罩点击关闭;closeOnMask=false 时不关闭",
    "选项行高不小于 44px,滚动容器 -webkit-overflow-scrolling: touch",
    "点击选项只改本列高亮,不改宿主的值;确认时才提交",
    "确定 / 取消按钮热区不小于 44px",
    "取消即返回:点取消或遮罩丢弃本次点选,宿主侧的值回到打开前的状态"
  ],
  "doNotInvent": [
    "选项数据源与联动规则(由宿主传入 columns,本组件不发明城市库或级联关系)",
    "滚轮惯性 / 吸附动画的物理参数",
    "搜索过滤与键盘输入定位(那是 Input / Search 的职责)",
    "多选(一次选多个值)——本组件是「多列各选一项」,不是「一列选多项」"
  ],
  "unknowns": [
    "面板最大高度是否应随列数增长(当前固定 max-height 一列 200px)",
    "是否要支持「不选」的空值项",
    "列数上限(当前实现不限制,但三列以上在 375px 宽度下每列会很窄)"
  ],
  "interaction": [
    "遮罩点击关闭;closeOnMask=false 时不关闭",
    "选项行高不小于 44px,滚动容器 -webkit-overflow-scrolling: touch",
    "点击选项只改本列高亮(同列其余项取消高亮),不改宿主的值;确认时才提交",
    "确定 / 取消按钮热区不小于 44px",
    "取消防返回:点取消或遮罩丢弃本次点选,宿主侧的值回到打开前的状态"
  ],
  "accessibility": [
    "浮层 role=\"dialog\" + aria-modal=\"true\",标题元素 id 由 aria-labelledby 指向",
    "每列 role=\"listbox\" + aria-label,选项 role=\"option\" + aria-selected",
    "遮罩 aria-hidden=\"true\"(纯装饰,读屏不播报)",
    "选中值以文本呈现在标题里(不依赖视觉滚动位置),收起态写 aria-hidden=\"true\"",
    "禁用项写 aria-disabled=\"true\",读屏播报不可用"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "mode",
        "type": "'single' | 'multiple'",
        "default": "'single'",
        "desc": "变体 mode:single 单列滚轮,multiple 多列滚轮(规格 §43.3)",
        "required": false
      },
      {
        "name": "round",
        "type": "boolean",
        "default": "false",
        "desc": "变体 round:靠内容一侧是否切圆角(规格 §43.3)",
        "required": false
      },
      {
        "name": "open",
        "type": "boolean",
        "default": "false",
        "desc": "状态 open:浮层是否展开(规格 §43.4)",
        "required": false
      },
      {
        "name": "closeOnMask",
        "type": "boolean",
        "default": "true",
        "desc": "遮罩点击是否关闭(规格 §43.5)",
        "required": false
      },
      {
        "name": "value",
        "type": "Array",
        "default": "[]",
        "desc": "当前选中值,按列顺序排列(单列时长度 1);用于让选中项可见(规格 §43.6)",
        "required": false
      },
      {
        "name": "columns",
        "type": "Array<{ label: string, options: Array }>",
        "default": "[]",
        "desc": "列定义(宿主提供,本组件不发明选项数据源)(规格 §43.7)",
        "required": false
      },
      {
        "name": "disabled",
        "type": "boolean",
        "default": "false",
        "desc": "变体 disabled:整块置灰且不响应(规格 §43.4)",
        "required": false
      },
      {
        "name": "title",
        "type": "string",
        "default": "''",
        "desc": "面板标题(空串时用默认「选择」)(规格 §43.2 picker__header)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "confirm",
        "params": "value: Array",
        "desc": "点击确定时触发,回传各列当前选中值(规格 §43.5)"
      },
      {
        "name": "close",
        "params": "—",
        "desc": "点击取消或遮罩(closeOnMask=true 时)时触发(规格 §43.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "自定义内容(追加在面板之前,常见用法是放触发器)"
      }
    ]
  },
  "variantClasses": {
    "mode": {
      "single": [
        ".kole-m-picker--single"
      ],
      "multiple": [
        ".kole-m-picker--multiple"
      ]
    },
    "round": {
      "false": [],
      "true": [
        ".kole-m-picker--round"
      ]
    }
  },
  "demos": [
    {
      "id": "single",
      "group": "01 组件类型",
      "title": "单列滚轮",
      "desc": "mode=single:点选项即高亮,选中项用品牌色并与 aria-selected 同步,不依赖视觉位置。",
      "variant": "mode=single"
    },
    {
      "id": "multiple",
      "group": "01 组件类型",
      "title": "多列滚轮",
      "desc": "mode=multiple:时 / 分两列各自独立选中,确认时一并回传。",
      "variant": "mode=multiple"
    },
    {
      "id": "selected",
      "group": "02 组件状态",
      "title": "选中态与禁用项",
      "desc": "选中用 is-selected + aria-selected 双通道;禁用项置灰并写 aria-disabled,点击不响应。",
      "variant": "状态 selected | disabled"
    },
    {
      "id": "closed",
      "group": "02 组件状态",
      "title": "收起态",
      "desc": "默认态:遮罩 pointer-events: none,点触发器才把浮层与遮罩一起拉开。",
      "variant": "状态 closed"
    }
  ],
  "related": [
    {
      "slug": "mobile-popup",
      "why": "通用弹出层;本组件就是底部浮层形态,不要在弹出层里再套一层选择器"
    },
    {
      "slug": "actionsheet",
      "why": "动作面板也是底部升起的选项列表,但面向「点一下即执行」,不区分确定 / 取消"
    },
    {
      "slug": "mobile-datepicker",
      "why": "同样是底部滚轮,但选项是年月日这类有历法语义的日期时才用它"
    },
    {
      "slug": "mobile-cascader",
      "why": "选项之间有层级联动(省 → 市 → 区)时用级联选择器,而不是自己拼多列"
    },
    {
      "slug": "mobile-input",
      "why": "选项数量不定或需要用户自由输入时用它,不要用选择器硬塞长列表"
    }
  ]
}