单选框Radio
在一组互斥选项里选且只选一项(支付方式、配送时效、发票类型)
数据录入 规格 33 · 单选框 Radio 6 端实现 触摸优先
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">
<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/mobile-radio.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Radio.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
互斥:选中一项会自动清掉同组其它项;整行热区不小于 44px。
查看代码(演示页原文 · 18 行)
<section class="demo-block" data-demo="basic">
<p class="demo-label">单选组(互斥:选中一项会自动清掉同组其它项;整行热区 ≥44px)</p>
<div class="kole-m-radio-group" id="rg-basic" role="radiogroup" aria-label="支付方式" data-value="wechat" data-assert="radio-basic">
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true" data-radio="wechat">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">微信支付</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" data-radio="alipay"
data-behavior="click-sets-attr:#rg-basic|data-value|alipay">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">支付宝</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" data-radio="card">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">对公转账</span>
</button>
</div>
</section>orientation=horizontal:值少、文案短时用,不再画分隔线。
查看代码(演示页原文 · 17 行)
<section class="demo-block" data-demo="horizontal">
<p class="demo-label">横向排列(orientation=horizontal:值少、文案短时用,不再画分隔线)</p>
<div class="demo-pad" style="display: flex; justify-content: flex-start;">
<div class="kole-m-radio-group kole-m-radio-group--horizontal" id="rg-horizontal" role="radiogroup"
aria-label="配送方式" data-value="standard" data-assert="radio-horizontal">
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true" data-radio="standard">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">标准</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" data-radio="express"
data-behavior="click-sets-attr:#rg-horizontal|data-value|express">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">加急</span>
</button>
</div>
</div>
</section>button=true:无圆点,选中态用品牌色边框 + 浅底。
查看代码(演示页原文 · 9 行)
<section class="demo-block" data-demo="button">
<p class="demo-label">胶囊按钮式(button=true:无圆点,选中态用品牌色边框 + 浅底)</p>
<div class="demo-pad" style="display: flex; gap: var(--kole-space-8);" data-assert="radio-button">
<button class="kole-m-radio kole-m-radio--button" type="button" role="radio" aria-checked="false" id="rd-pill-a"
data-behavior="click-toggles-class:#rd-pill-a|is-checked">全部</button>
<button class="kole-m-radio kole-m-radio--button is-checked" type="button" role="radio" aria-checked="true">已发货</button>
<button class="kole-m-radio kole-m-radio--button" type="button" role="radio" aria-checked="false">待付款</button>
</div>
</section>02 组件状态
两者不能只靠颜色区分:内圈实心点 + aria-checked 双通道。
查看代码(演示页原文 · 16 行)
<section class="demo-block" data-demo="checked">
<p class="demo-label">选中与未选中(两者不能只靠颜色区分:内圈实心点 + aria-checked 双通道)</p>
<div class="demo-pad" data-assert="radio-checked">
<div class="kole-m-radio-group">
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" id="rd-plain"
data-behavior="click-toggles-class:#rd-plain|is-checked">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">未选中:空心环(点它即选中,同时清掉上一项)</span>
</button>
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true" id="rd-single">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">选中:实心内圈</span>
</button>
</div>
</div>
</section>置灰且不响应:原生 disabled 让读屏播报不可用、键盘跳过。
查看代码(演示页原文 · 16 行)
<section class="demo-block" data-demo="disabled">
<p class="demo-label">禁用项(置灰且不响应:原生 disabled 让读屏播报不可用、键盘跳过)</p>
<div class="kole-m-radio-group" role="radiogroup" aria-label="发票类型" data-assert="radio-disabled">
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">电子普通发票</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" aria-disabled="true" disabled>
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">
增值税专用发票
<span class="kole-m-radio__desc">需先完成企业认证</span>
</span>
</button>
</div>
</section>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
options | Array | [] | 选项数组,每项 { value, label, desc, disabled }(规格 §33.2) | N |
value | string | '' | 受控选中值,与 aria-checked 同步写(规格 §33.5) | N |
orientation | 'vertical' | 'horizontal' | 'vertical' | 变体 orientation:纵向(画分隔线)还是横向排列(规格 §33.3) | N |
button | boolean | false | 变体 button:胶囊标签式(无圆点),用于筛选条(规格 §33.3) | N |
disabled | boolean | false | 状态 disabled:整组置灰且不可聚焦(规格 §33.4) | N |
label | string | '单选项' | 分组名称,落到 radiogroup 的 aria-label(规格 §33.6) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
change | (value) | 选中某项时触发,回传该项的值(规格 §33.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 额外选项内容(宿主自己渲染的项,追在 options 之后,规格 §33.2 item) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-radio-size | 20px | 圆点直径(视觉尺寸) |
--kole-m-radio-gap | var(--kole-space-12) | 圆点与文字的间距 |
何时使用
- 在一组互斥选项里选且只选一项(支付方式、配送时效、发票类型)
- 触屏没有鼠标悬停可以预告状态,选中必须同时有形状差异(圆点内圈实心)与 aria-checked,不能只靠颜色
- 整行(圆点 + 文字)都是热区,行高不小于 44px
- 一次触摸即选中,不需要二次确认;选中后不能再点回空值
- 互斥由组件负责:选中一项会立即清掉同组其它项的选中态,不留下两个选中
交互与触控
- 整行(圆点 + 文字)都是热区,行高不小于 44px;横向组里每项自身也保持这个边长
- 一次触摸即选中,不需要二次确认;选中后**不能**再点回空值(需要清空由宿主提供额外的「清除」动作)
- 互斥由组件负责:选中一项会立即清掉同组其它项的选中态,不留下两个选中
- 切换动效是圆点内圈 150ms 缩放;减少动态偏好下瞬时切换
- 选中后立即触发 change 事件,回传该项的值
无障碍
- 组用 role="radiogroup" + aria-label 说明分组名称
- 每项是原生 button + role="radio" + aria-checked(读屏会播报「单选按钮,已选中/未选中」)
- 圆点与内圈是纯装饰,对读屏隐藏(aria-hidden),语义全靠 role="radio"
- 禁用项用原生 disabled 并补 aria-disabled="true"
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 多选框Checkbox | 选项之间可以同时成立(多选、含全选)时用 Checkbox;只有一项能成立时才用 Radio |
| 开关Switch | 「开 / 关」这类即时生效的二值状态用 Switch(即时切换);需要随表单一起提交的互斥选择用 Radio |
| 表单Form | 单选组作为表单里的一个字段时,标签、必填星号与错误提示由 Form 的 item 提供 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 「取消选中」的回到空值交互(单选的语义就是必有一项) |
| 禁止发明 | 横向组自动换行的列数策略(由宿主按文案长度决定,组件只负责 flex-wrap) |
| 禁止发明 | 与表单一起提交时的隐藏字段(由宿主添加) |
| 禁止发明 | 选项内容的异步加载与搜索过滤 |
| 规格未定 | 横向组超过一行时是否改成纵向(当前包裹后当行处理) |
| 规格未定 | 圆点尺寸是否随字号一起放大(当前固定 20px) |
| 规格未定 | desc 说明行是否允许两行以上(当前单行省略) |
结构(anatomy)
| 字段 | 说明 |
|---|---|
group | 根元素,role="radiogroup" + aria-label 说明这组在选什么 |
item | 单个选项,整行都是热区(原生 button + role="radio") |
icon | 圆点,未选中是空心环、选中是实心圆 + 内圈反色点 |
label | 选项文字,占满剩余宽度 |
desc | 可选说明行,跟在文字下方(如「需先完成企业认证」) |
state | 选中态 is-checked 与 aria-checked 双写 |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
orientation | vertical / horizontal | vertical (由数据驱动,无专属类) horizontal .kole-m-radio-group--horizontal |
button | false / true | false (由数据驱动,无专属类) true .kole-m-radio--button |
代表变体
| 变体 | 标签 |
|---|---|
orientation=vertical · button=false | 默认(圆点 + 文字,纵向排列并画分隔线) |
orientation=horizontal · button=false | 横向排列(值少、文案短时用) |
orientation=vertical · button=true | 胶囊标签式(无圆点,筛选条用) |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Radio.css · 纯样式(CSS) · 152 行
/* Kole UI Mobile · Radio 样式 — 对齐移动端规格 §33
单选框:一组里只能选一项。触屏上没有鼠标悬停,所以选中态必须同时有
形状差异(内圈实心点)与 aria-checked,不能只靠颜色。
整行(圆点 + 文字)都是热区,行高不小于 44px;横向组里每项自身仍要够 44px。 */
.kole-m-radio-group {
/* 组件级变量:业务侧可在容器上覆盖 */
--kole-m-radio-size: 20px; /* 圆点直径(视觉尺寸) */
--kole-m-radio-gap: var(--kole-space-12); /* 圆点与文字的间距 */
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
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;
}
/* 变体 orientation=horizontal:横向排列(值少、文案短时用) */
.kole-m-radio-group--horizontal {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
/* 组内相邻项的分隔线:只在纵向排列时画(横向排列靠间距分组) */
.kole-m-radio-group:not(.kole-m-radio-group--horizontal) .kole-m-radio + .kole-m-radio {
border-top: 1px solid var(--kole-color-border);
}
.kole-m-radio {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--kole-m-radio-gap);
width: 100%;
min-height: var(--kole-m-touch-target);
padding: var(--kole-space-12) var(--kole-m-gutter);
margin: 0;
border: 0;
background: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: inherit;
line-height: 1.4;
text-align: start;
cursor: pointer;
touch-action: manipulation;
}
/* 横向组里的项:宽自适应,左右留白收窄(组自身负责外边距) */
.kole-m-radio-group--horizontal .kole-m-radio {
width: auto;
padding-inline: var(--kole-space-12);
}
.kole-m-radio:active:not(:disabled) { background: var(--kole-color-table-header-bg); }
/* 圆点:未选中是空心环,选中是实心圆 + 内圈反色点(形状差异,不只靠颜色) */
.kole-m-radio__icon {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: var(--kole-m-radio-size);
height: var(--kole-m-radio-size);
border: 1px solid var(--kole-color-text-placeholder);
border-radius: 50%;
background: var(--kole-color-card-bg);
transition: border-color 150ms var(--kole-ease-standard), background-color 150ms var(--kole-ease-standard);
}
.kole-m-radio__icon::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--kole-color-text-inverse);
transform: scale(0);
transition: transform 150ms var(--kole-ease-standard);
}
/* 状态 checked:圆环填品牌色,内圈出现反色实心点 */
.kole-m-radio.is-checked { color: var(--kole-color-text-title); }
.kole-m-radio.is-checked .kole-m-radio__icon {
border-color: var(--kole-color-brand);
background: var(--kole-color-brand);
}
.kole-m-radio.is-checked .kole-m-radio__icon::after { transform: scale(1); }
.kole-m-radio__label {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
/* 说明行:标签下的一行小字(单行省略,不把行撑成两行以上) */
.kole-m-radio__desc {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--kole-m-font-size-label);
color: var(--kole-color-text-secondary);
}
/* 变体 button=true:胶囊标签式(筛选条一类的轻量场合,无圆点) */
.kole-m-radio--button {
width: auto;
min-height: var(--kole-m-touch-target);
padding: var(--kole-space-8) var(--kole-space-16);
border: 1px solid var(--kole-color-border);
border-radius: 999px;
background: var(--kole-color-card-bg);
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
.kole-m-radio--button .kole-m-radio__icon { display: none; }
.kole-m-radio--button.is-checked {
border-color: var(--kole-color-brand);
background: var(--kole-color-brand-bg);
color: var(--kole-color-brand);
}
/* 状态 disabled:置灰且不可聚焦(原生 disabled) */
.kole-m-radio:disabled {
color: var(--kole-color-text-disabled);
background: var(--kole-color-card-bg);
cursor: not-allowed;
}
.kole-m-radio:disabled .kole-m-radio__icon {
border-color: var(--kole-color-text-disabled);
background: var(--kole-color-disabled-bg);
}
.kole-m-radio--button:disabled {
border-color: var(--kole-color-border);
background: var(--kole-color-disabled-bg);
color: var(--kole-color-text-disabled);
}
.kole-m-radio:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
frameworks-mobile/Radio.html · H5 原生(无框架) · 160 行
<!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 · Radio(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Radio.css">
<style>
body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
.demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
.demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
.demo-pad { padding: var(--kole-m-gutter); 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">单选组(互斥:选中一项会自动清掉同组其它项;整行热区 ≥44px)</p>
<div class="kole-m-radio-group" id="rg-basic" role="radiogroup" aria-label="支付方式" data-value="wechat" data-assert="radio-basic">
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true" data-radio="wechat">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">微信支付</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" data-radio="alipay"
data-behavior="click-sets-attr:#rg-basic|data-value|alipay">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">支付宝</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" data-radio="card">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">对公转账</span>
</button>
</div>
</section>
<section class="demo-block" data-demo="checked">
<p class="demo-label">选中与未选中(两者不能只靠颜色区分:内圈实心点 + aria-checked 双通道)</p>
<div class="demo-pad" data-assert="radio-checked">
<div class="kole-m-radio-group">
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" id="rd-plain"
data-behavior="click-toggles-class:#rd-plain|is-checked">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">未选中:空心环(点它即选中,同时清掉上一项)</span>
</button>
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true" id="rd-single">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">选中:实心内圈</span>
</button>
</div>
</div>
</section>
<section class="demo-block" data-demo="horizontal">
<p class="demo-label">横向排列(orientation=horizontal:值少、文案短时用,不再画分隔线)</p>
<div class="demo-pad" style="display: flex; justify-content: flex-start;">
<div class="kole-m-radio-group kole-m-radio-group--horizontal" id="rg-horizontal" role="radiogroup"
aria-label="配送方式" data-value="standard" data-assert="radio-horizontal">
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true" data-radio="standard">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">标准</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" data-radio="express"
data-behavior="click-sets-attr:#rg-horizontal|data-value|express">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">加急</span>
</button>
</div>
</div>
</section>
<section class="demo-block" data-demo="button">
<p class="demo-label">胶囊按钮式(button=true:无圆点,选中态用品牌色边框 + 浅底)</p>
<div class="demo-pad" style="display: flex; gap: var(--kole-space-8);" data-assert="radio-button">
<button class="kole-m-radio kole-m-radio--button" type="button" role="radio" aria-checked="false" id="rd-pill-a"
data-behavior="click-toggles-class:#rd-pill-a|is-checked">全部</button>
<button class="kole-m-radio kole-m-radio--button is-checked" type="button" role="radio" aria-checked="true">已发货</button>
<button class="kole-m-radio kole-m-radio--button" type="button" role="radio" aria-checked="false">待付款</button>
</div>
</section>
<section class="demo-block" data-demo="disabled">
<p class="demo-label">禁用项(置灰且不响应:原生 disabled 让读屏播报不可用、键盘跳过)</p>
<div class="kole-m-radio-group" role="radiogroup" aria-label="发票类型" data-assert="radio-disabled">
<button class="kole-m-radio is-checked" type="button" role="radio" aria-checked="true">
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">电子普通发票</span>
</button>
<button class="kole-m-radio" type="button" role="radio" aria-checked="false" aria-disabled="true" disabled>
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">
增值税专用发票
<span class="kole-m-radio__desc">需先完成企业认证</span>
</span>
</button>
</div>
</section>
</div>
<script>
/* 演示页脚本:真实互斥。
- 点一项:该项 is-checked + aria-checked="true",同组其它项一律清掉
- 组根同时写 data-value(当前值),供宿主持久化
真实业务里这份状态由宿主管理(受控 value + change 事件),此处是最小可运行实现。 */
(function () {
Array.prototype.forEach.call(document.querySelectorAll('.kole-m-radio-group'), function (group) {
var items = Array.prototype.slice.call(group.querySelectorAll('.kole-m-radio'));
if (!items.length) return;
function select(target) {
items.forEach(function (it) {
var on = it === target;
it.classList.toggle('is-checked', on);
it.setAttribute('aria-checked', on ? 'true' : 'false');
});
group.setAttribute('data-value', target.getAttribute('data-radio') || '');
}
group.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.kole-m-radio') : null;
if (!btn || !group.contains(btn)) return;
if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return;
select(btn);
});
});
/* 独立胶囊按钮(不组成 radiogroup):点一下切自己的选中态 */
Array.prototype.forEach.call(document.querySelectorAll('.kole-m-radio--button'), function (btn) {
if (btn.closest('.kole-m-radio-group') || btn.disabled) return;
btn.addEventListener('click', function () {
btn.classList.toggle('is-checked');
btn.setAttribute('aria-checked', btn.classList.contains('is-checked') ? 'true' : 'false');
});
});
})();
</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/Radio.jsx · React · 65 行
import React from 'react';
import './Radio.css';
/* 单选框(移动端)— 规格 §33
一组里只能选一项(互斥由本组件负责:选中一项会清掉同组其它项)。
触屏上没有鼠标悬停,选中态必须同时有形状差异(内圈实心点)与 aria-checked,不能只靠颜色。
整行(圆点 + 文字)都是 44px 高的热区;横向组里每项自身也保持 44px 最小边长。
本端不发明「取消选中」(单选一旦选中不可回到空值,规格 §33.5)。 */
export default function Radio({
options = [],
value = '',
orientation = 'vertical',
button = false,
disabled = false,
label = '单选项',
onChange,
children = null,
}) {
const cls =
'kole-m-radio-group' +
(orientation === 'horizontal' ? ' kole-m-radio-group--horizontal' : '') +
(disabled ? ' is-disabled' : '');
function pick(key) {
if (disabled) return;
if (onChange) onChange(key);
}
return (
<div className={cls} role="radiogroup" aria-label={label}>
{options.map((opt, i) => {
const item = opt || {};
const key = item.value !== undefined ? item.value : String(i);
const checked = key === value;
return (
<button
className={
'kole-m-radio' + (button ? ' kole-m-radio--button' : '') + (checked ? ' is-checked' : '')
}
key={String(key)}
type="button"
role="radio"
aria-checked={checked ? 'true' : 'false'}
aria-disabled={item.disabled || disabled ? 'true' : undefined}
disabled={!!(item.disabled || disabled)}
onClick={() => {
if (item.disabled) return;
pick(key);
}}
>
{/* 圆点:装饰性(语义由 role="radio" + aria-checked 承担) */}
<span className="kole-m-radio__icon" aria-hidden="true" />
<span className="kole-m-radio__label">
{item.label}
{item.desc ? <span className="kole-m-radio__desc">{item.desc}</span> : null}
</span>
</button>
);
})}
{children}
</div>
);
}
frameworks-mobile/Radio.vue2.vue · Vue 2 · 63 行
<template>
<div class="kole-m-radio-group" :class="groupClass" role="radiogroup" :aria-label="label">
<button
v-for="(item, i) in options"
:key="String(keyOf(item, i))"
class="kole-m-radio"
:class="{ 'kole-m-radio--button': button, 'is-checked': keyOf(item, i) === value }"
type="button"
role="radio"
:aria-checked="keyOf(item, i) === value ? 'true' : 'false'"
:aria-disabled="item && (item.disabled || disabled) ? 'true' : null"
:disabled="!!(item && (item.disabled || disabled))"
@click="pick(item, i)"
>
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">
{{ item && item.label }}
<span v-if="item && item.desc" class="kole-m-radio__desc">{{ item.desc }}</span>
</span>
</button>
<slot></slot>
</div>
</template>
<script>
/* 单选框(移动端)— 规格 §33
一组里只能选一项(互斥由本组件负责:选中一项会清掉同组其它项)。
触屏上没有鼠标悬停,选中态必须同时有形状差异(内圈实心点)与 aria-checked,不能只靠颜色。
整行(圆点 + 文字)都是 44px 高的热区;横向组里每项自身也保持 44px 最小边长。
本端不发明「取消选中」(单选一旦选中不可回到空值,规格 §33.5)。 */
export default {
name: 'KoleMRadio',
props: {
options: { type: Array, default: function () { return []; } },
value: { type: String, default: '' },
orientation: { type: String, default: 'vertical' },
button: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
label: { type: String, default: '单选项' }
},
computed: {
groupClass: function () {
return [
this.orientation === 'horizontal' ? 'kole-m-radio-group--horizontal' : '',
this.disabled ? 'is-disabled' : ''
].filter(Boolean);
}
},
methods: {
keyOf: function (item, i) {
return item && item.value !== undefined ? item.value : String(i);
},
pick: function (item, i) {
if (this.disabled || (item && item.disabled)) return;
this.$emit('change', this.keyOf(item, i));
}
}
};
</script>
<style src="./Radio.css"></style>
frameworks-mobile/Radio.vue3.vue · Vue 3 · 59 行
<template>
<div class="kole-m-radio-group" :class="groupClass" role="radiogroup" :aria-label="label">
<button
v-for="(item, i) in options"
:key="String(keyOf(item, i))"
class="kole-m-radio"
:class="{ 'kole-m-radio--button': button, 'is-checked': keyOf(item, i) === value }"
type="button"
role="radio"
:aria-checked="keyOf(item, i) === value ? 'true' : 'false'"
:aria-disabled="item && (item.disabled || disabled) ? 'true' : null"
:disabled="!!(item && (item.disabled || disabled))"
@click="pick(item, i)"
>
<span class="kole-m-radio__icon" aria-hidden="true"></span>
<span class="kole-m-radio__label">
{{ item && item.label }}
<span v-if="item && item.desc" class="kole-m-radio__desc">{{ item.desc }}</span>
</span>
</button>
<slot></slot>
</div>
</template>
<script setup>
/* 单选框(移动端)— 规格 §33
一组里只能选一项(互斥由本组件负责:选中一项会清掉同组其它项)。
触屏上没有鼠标悬停,选中态必须同时有形状差异(内圈实心点)与 aria-checked,不能只靠颜色。
整行(圆点 + 文字)都是 44px 高的热区;横向组里每项自身也保持 44px 最小边长。
本端不发明「取消选中」(单选一旦选中不可回到空值,规格 §33.5)。 */
import { computed } from 'vue';
const props = defineProps({
options: { type: Array, default: () => [] },
value: { type: String, default: '' },
orientation: { type: String, default: 'vertical' },
button: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
label: { type: String, default: '单选项' }
});
const emit = defineEmits(['change']);
const groupClass = computed(() => [
props.orientation === 'horizontal' ? 'kole-m-radio-group--horizontal' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function keyOf(item, i) {
return item && item.value !== undefined ? item.value : String(i);
}
function pick(item, i) {
if (props.disabled || (item && item.disabled)) return;
emit('change', keyOf(item, i));
}
</script>
<style src="./Radio.css"></style>
frameworks-mobile/Radio.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 180 行
<template>
<view class="kole-m-radio-group" :class="groupClass" role="radiogroup" :aria-label="label">
<view
v-for="(item, i) in options"
:key="String(keyOf(item, i))"
class="kole-m-radio"
:class="{ 'kole-m-radio--button': button, 'is-checked': keyOf(item, i) === value }"
:role="item && (item.disabled || disabled) ? '' : 'radio'"
:aria-checked="keyOf(item, i) === value ? 'true' : 'false'"
:aria-disabled="item && (item.disabled || disabled) ? 'true' : 'false'"
@tap="pick(item, i)"
>
<view class="kole-m-radio__icon" />
<view class="kole-m-radio__label">
<text>{{ item && item.label }}</text>
<text v-if="item && item.desc" class="kole-m-radio__desc">{{ item.desc }}</text>
</view>
</view>
<slot></slot>
</view>
</template>
<script setup>
/* uni-app 端 · 单选框(移动端)— 规格 §33
跨端差异:
① 项用 view + role="radio"(小程序没有可聚焦的原生 button 语义差异),点击用 @tap;
② 选中态靠 is-checked 类 + aria-checked 双写;圆点用 view + ::after 内圈(与 H5 同一视觉规则);
③ 禁用不用原生 disabled(view 没有),改为 aria-disabled + is-disabled 类,代码里自己拦点击。
尺寸用 rpx:88rpx = 375pt 下的 44px 触控最小边长,圆点 40rpx = 20px。 */
import { computed } from 'vue';
const props = defineProps({
options: { type: Array, default: () => [] },
value: { type: String, default: '' },
orientation: { type: String, default: 'vertical' },
button: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
label: { type: String, default: '单选项' }
});
const emit = defineEmits(['change']);
const groupClass = computed(() => [
props.orientation === 'horizontal' ? 'kole-m-radio-group--horizontal' : '',
props.disabled ? 'is-disabled' : ''
].filter(Boolean));
function keyOf(item, i) {
return item && item.value !== undefined ? item.value : String(i);
}
function pick(item, i) {
if (props.disabled || (item && item.disabled)) return;
emit('change', keyOf(item, i));
}
</script>
<style>
.kole-m-radio-group {
--kole-m-radio-size: 40rpx; /* 圆点直径(20px @375pt) */
--kole-m-radio-gap: 24rpx; /* 圆点与文字的间距 */
--kole-m-touch-target: 88rpx;
--kole-m-font-size-body: 32rpx;
--kole-m-font-size-label: 28rpx;
--kole-m-gutter: 32rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-body);
}
.kole-m-radio-group--horizontal {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.kole-m-radio-group:not(.kole-m-radio-group--horizontal) .kole-m-radio + .kole-m-radio {
border-top: 1rpx solid var(--kole-color-border);
}
.kole-m-radio {
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
min-height: var(--kole-m-touch-target);
padding: 24rpx var(--kole-m-gutter);
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-body);
font-size: inherit;
}
.kole-m-radio-group--horizontal .kole-m-radio {
width: auto;
padding-left: 24rpx;
padding-right: 24rpx;
}
/* 状态 active:按下反馈 */
.kole-m-radio:active { background-color: var(--kole-color-table-header-bg); }
/* 圆点:未选中空心环,选中实心 + 内圈反色点(形状差异,不只靠颜色) */
.kole-m-radio__icon {
position: relative;
flex-shrink: 0;
box-sizing: border-box;
width: var(--kole-m-radio-size);
height: var(--kole-m-radio-size);
margin-right: var(--kole-m-radio-gap);
border: 1rpx solid var(--kole-color-text-placeholder);
border-radius: 50%;
background-color: var(--kole-color-card-bg);
}
.kole-m-radio__icon::after {
content: '';
position: absolute;
top: 6rpx;
left: 6rpx;
width: 28rpx;
height: 28rpx;
border-radius: 50%;
background-color: var(--kole-color-text-inverse);
transform: scale(0);
}
/* 状态 checked */
.kole-m-radio.is-checked .kole-m-radio__icon {
border-color: var(--kole-color-brand);
background-color: var(--kole-color-brand);
}
.kole-m-radio.is-checked .kole-m-radio__icon::after { transform: scale(1); }
.kole-m-radio__label { flex: 1; display: flex; flex-direction: column; }
.kole-m-radio__desc {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--kole-m-font-size-label);
color: var(--kole-color-text-secondary);
}
/* 变体 button=true:胶囊标签式(无圆点) */
.kole-m-radio--button {
width: auto;
padding: 16rpx 32rpx;
border: 1rpx solid var(--kole-color-border);
border-radius: 999rpx;
color: var(--kole-color-text-secondary);
font-size: var(--kole-m-font-size-label);
}
.kole-m-radio--button .kole-m-radio__icon { display: none; }
.kole-m-radio--button.is-checked {
border-color: var(--kole-color-brand);
background-color: var(--kole-color-brand-bg);
color: var(--kole-color-brand);
}
/* 状态 disabled:置灰且不响应 */
.kole-m-radio[aria-disabled='true'] { color: var(--kole-color-text-disabled); }
.kole-m-radio[aria-disabled='true'] .kole-m-radio__icon {
border-color: var(--kole-color-text-disabled);
background-color: var(--kole-color-disabled-bg);
}
.kole-m-radio--button[aria-disabled='true'] {
border-color: var(--kole-color-border);
background-color: var(--kole-color-disabled-bg);
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-radio.json(点击展开原始 JSON)
{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "33 · 单选框 Radio",
"confidence": "high",
"slug": "mobile-radio",
"name": "单选框 Radio",
"semanticTypeCandidates": [
"radio",
"radio-group",
"single-choice"
],
"variantDimensions": [
{
"name": "orientation",
"values": [
"vertical",
"horizontal"
]
},
{
"name": "button",
"values": [
"false",
"true"
]
}
],
"representativeVariants": [
{
"orientation": "vertical",
"button": "false",
"label": "默认(圆点 + 文字,纵向排列并画分隔线)"
},
{
"orientation": "horizontal",
"button": "false",
"label": "横向排列(值少、文案短时用)"
},
{
"orientation": "vertical",
"button": "true",
"label": "胶囊标签式(无圆点,筛选条用)"
}
],
"anatomy": {
"group": "根元素,role=\"radiogroup\" + aria-label 说明这组在选什么",
"item": "单个选项,整行都是热区(原生 button + role=\"radio\")",
"icon": "圆点,未选中是空心环、选中是实心圆 + 内圈反色点",
"label": "选项文字,占满剩余宽度",
"desc": "可选说明行,跟在文字下方(如「需先完成企业认证」)",
"state": "选中态 is-checked 与 aria-checked 双写"
},
"structurePatterns": {
"orientation": "vertical(纵向排列,组内画分隔线)/ horizontal(横向排列,靠间距分组)",
"button": "false(圆点 + 文字)/ true(胶囊标签式,无圆点)"
},
"usageHints": [
"在一组互斥选项里选且只选一项(支付方式、配送时效、发票类型)",
"触屏没有鼠标悬停可以预告状态,选中必须同时有形状差异(圆点内圈实心)与 aria-checked,不能只靠颜色",
"整行(圆点 + 文字)都是热区,行高不小于 44px",
"一次触摸即选中,不需要二次确认;选中后不能再点回空值",
"互斥由组件负责:选中一项会立即清掉同组其它项的选中态,不留下两个选中"
],
"doNotInvent": [
"「取消选中」的回到空值交互(单选的语义就是必有一项)",
"横向组自动换行的列数策略(由宿主按文案长度决定,组件只负责 flex-wrap)",
"与表单一起提交时的隐藏字段(由宿主添加)",
"选项内容的异步加载与搜索过滤"
],
"unknowns": [
"横向组超过一行时是否改成纵向(当前包裹后当行处理)",
"圆点尺寸是否随字号一起放大(当前固定 20px)",
"desc 说明行是否允许两行以上(当前单行省略)"
],
"interaction": [
"整行(圆点 + 文字)都是热区,行高不小于 44px;横向组里每项自身也保持这个边长",
"一次触摸即选中,不需要二次确认;选中后**不能**再点回空值(需要清空由宿主提供额外的「清除」动作)",
"互斥由组件负责:选中一项会立即清掉同组其它项的选中态,不留下两个选中",
"切换动效是圆点内圈 150ms 缩放;减少动态偏好下瞬时切换",
"选中后立即触发 change 事件,回传该项的值"
],
"accessibility": [
"组用 role=\"radiogroup\" + aria-label 说明分组名称",
"每项是原生 button + role=\"radio\" + aria-checked(读屏会播报「单选按钮,已选中/未选中」)",
"圆点与内圈是纯装饰,对读屏隐藏(aria-hidden),语义全靠 role=\"radio\"",
"禁用项用原生 disabled 并补 aria-disabled=\"true\""
],
"api": {
"source": "implementation",
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
"props": [
{
"name": "options",
"type": "Array",
"default": "[]",
"desc": "选项数组,每项 { value, label, desc, disabled }(规格 §33.2)",
"required": false
},
{
"name": "value",
"type": "string",
"default": "''",
"desc": "受控选中值,与 aria-checked 同步写(规格 §33.5)",
"required": false
},
{
"name": "orientation",
"type": "'vertical' | 'horizontal'",
"default": "'vertical'",
"desc": "变体 orientation:纵向(画分隔线)还是横向排列(规格 §33.3)",
"required": false
},
{
"name": "button",
"type": "boolean",
"default": "false",
"desc": "变体 button:胶囊标签式(无圆点),用于筛选条(规格 §33.3)",
"required": false
},
{
"name": "disabled",
"type": "boolean",
"default": "false",
"desc": "状态 disabled:整组置灰且不可聚焦(规格 §33.4)",
"required": false
},
{
"name": "label",
"type": "string",
"default": "'单选项'",
"desc": "分组名称,落到 radiogroup 的 aria-label(规格 §33.6)",
"required": false
}
],
"events": [
{
"name": "change",
"params": "(value)",
"desc": "选中某项时触发,回传该项的值(规格 §33.5)"
}
],
"slots": [
{
"name": "default",
"desc": "额外选项内容(宿主自己渲染的项,追在 options 之后,规格 §33.2 item)"
}
]
},
"variantClasses": {
"orientation": {
"vertical": [],
"horizontal": [
".kole-m-radio-group--horizontal"
]
},
"button": {
"false": [],
"true": [
".kole-m-radio--button"
]
}
},
"demos": [
{
"id": "basic",
"group": "01 组件类型",
"title": "单选组",
"desc": "互斥:选中一项会自动清掉同组其它项;整行热区不小于 44px。",
"variant": "orientation=vertical"
},
{
"id": "horizontal",
"group": "01 组件类型",
"title": "横向排列",
"desc": "orientation=horizontal:值少、文案短时用,不再画分隔线。",
"variant": "orientation=horizontal"
},
{
"id": "button",
"group": "01 组件类型",
"title": "胶囊按钮式",
"desc": "button=true:无圆点,选中态用品牌色边框 + 浅底。",
"variant": "button=true"
},
{
"id": "checked",
"group": "02 组件状态",
"title": "选中与未选中",
"desc": "两者不能只靠颜色区分:内圈实心点 + aria-checked 双通道。",
"variant": "状态 checked"
},
{
"id": "disabled",
"group": "02 组件状态",
"title": "禁用项",
"desc": "置灰且不响应:原生 disabled 让读屏播报不可用、键盘跳过。",
"variant": "disabled=true"
}
],
"related": [
{
"slug": "mobile-checkbox",
"why": "选项之间可以同时成立(多选、含全选)时用 Checkbox;只有一项能成立时才用 Radio"
},
{
"slug": "mobile-switch",
"why": "「开 / 关」这类即时生效的二值状态用 Switch(即时切换);需要随表单一起提交的互斥选择用 Radio"
},
{
"slug": "mobile-form",
"why": "单选组作为表单里的一个字段时,标签、必填星号与错误提示由 Form 的 item 提供"
}
]
}