checkbox / radio / select

全库里最容易做坏的一组。浏览器的键盘行为是无障碍的地基—— 改坏了用鼠标完全测不出来,只有键盘用户会知道。

怎么验:先把鼠标移开,用 Tab 走进下面每一个控件, 用 Space 切换勾选,用 ↑↓ 切换 radio。 顶上那个面板会实时显示每一次键盘事件和焦点状态——那不是模拟的,是原生行为。
焦点元素:(还没聚焦任何控件)
选中状态:—
按 Tab 开始。事件会显示在这里。

checkbox

整个 label 都能点(input 透明铺满了它),但视觉方框只有 20px——触摸目标是 44px。

每月 1 日发一封,可随时退订

.choice__hint 是解释,不是标签的一部分: 它排在标签下方、和文字左对齐,并且用 aria-describedby 关联 —— 读屏念"订阅月度报告,复选框",再补一句说明。 (写进 label 里会变成名字的一部分,念起来像"订阅月度报告每月1日发一封可随时退订"。)

indeterminate(第三态)

"全选"框初始是半选(横线),因为下面只选了 2 个。 不做这个态,"全选"复选框就无法正确表达中间状态。

radio 组(fieldset + legend)

时间范围

用 ↑↓ 在组内移动, 移动即选中——这是原生 radio 行为,<div> 模拟不出来。
fieldset + legend 让屏幕阅读器播报"第几组、共几项、当前选中第几项"。 换成 <div>+<span> 这些信息全丢。

select

上下键选择,Enter 确认,Esc 关闭

用原生 select,不做自定义下拉。 移动端会弹出系统带 wheel 的原生选择器、语言跟随系统、无障碍由系统保证。 换成 <div> 模拟,这些全部要重写且必然漏一部分。
这里只改了外观(appearance:none + 自绘箭头), ↑↓ 选择、Enter 确认、Esc 关闭全部保留。 箭头写了 pointer-events:none,否则它会吃掉点击。

按住 Ctrl / ⌘ 多选

.select__control--auto:固定 44px 高度遇到换行文字会把第二行裁掉, 所以多选列表 / 长选项要把高度放开(height:auto + min-height:44px)。 这是原生 select,箭头就不需要了 —— 它本来就不是"点开下拉"的交互。

disabled

不用 opacity,用明确的低对比色组(对比度会变成不可控的随机数)。 disabled 之后 hover 不能有任何变化,否则看着像还能点。

对照:把原生勾选框藏起来的两种写法

❌ display:none:看不见,也 Tab 不到

按一次 Tab 就知道:焦点直接跳过它。 display:none 把这个 input 从 tab 顺序里删掉了, 键盘用户彻底到不了 —— 而鼠标点仍然有效,所以这个 bug 用鼠标永远测不出来。

✅ opacity:0 + 绝对定位:看不见,但还在 Tab 顺序里

本库的 input 是 opacity:0 铺满整个 label: 你看得见的是 .choice__mark,焦点环也画在它身上 (:focus-visible + .choice__mark)。

焦点环必须画在看得见的地方

input 是 opacity:0 的,所以它自己的 outline 你根本看不见。 焦点环必须画在紧邻的可见标记上:
.choice__input:focus-visible + .choice__mark

门禁也是这么查的——它只认 :focus-visible + 这种相邻写法, 单纯写 :focus-visible {} 判为不通过。