给 CellGroup 写「点击回显」示例时,我顺手用键盘试了一下:Tab 根本到不了那两行。
查下去发现不是示例的问题——Cell 的
clickable只加了@click,Grid 的宫格项有role="button"却没有tabindex。两个组件都只能鼠标点,键盘用户完全用不了。
一个可点击元素要满足三件事
这是我这次核对出来的清单:
| 条件 | 作用 | 缺了会怎样 |
|---|---|---|
role="button" | 告诉辅助技术"这是按钮" | 读屏念成一段普通文本 |
tabindex="0" | 进入 Tab 序列 | 键盘永远聚焦不到 |
| Enter / Space 触发 | 键盘等价操作 | 聚焦到了、按了没反应 |
三个缺一个都不算"可操作"。
Cell 修复前的实际属性:
role: "listitem"
tabindex: null
键盘处理: 无
看上去它是个列表项(合理,因为 CellGroup 有 role="list"),但它带 @click——语义说是"列表项",行为是"按钮",两者对不上。
修复:让语义跟着行为走
<div
ref="rootRef"
class="rn-cell"
:role="clickable ? 'button' : 'listitem'"
:tabindex="clickable ? 0 : undefined"
@click="onClick"
@keydown.enter.prevent="onKeydown"
@keydown.space.prevent="onKeydown"
>
关键点是不能用 event.currentTarget.click():测试环境下 currentTarget 在合成事件里可能是 null。改成用模板 ref 拿根元素:
function onKeydown() {
if (!props.clickable) return;
rootRef.value?.click(); // 走原生 click,事件对象与鼠标一致
}
这样键盘触发的 click 和鼠标触发的是同一个事件对象,业务侧不用区分来源。
Space 为什么必须 preventDefault
Enter 和 Space 在浏览器里的默认行为不一样:
| 键 | 在 <button> 上 | 在 div[role=button] 上 |
|---|---|---|
| Enter | 触发 click | 什么都不发生 |
| Space | 触发 click | 滚动页面 |
所以两个键都要自己接管,而且 Space 必须 preventDefault()——否则用户想"按空格激活",页面却往下滚了一屏。模板里写的是 @keydown.space.prevent,.prevent 是必须的,不是可选修饰。
这也是「自己实现按钮」比自己想象中麻烦的地方:原生 <button> 帮你处理了这些默认行为,换成 div 就得一条条补回来。
一个我没做的取舍:列表语义被打断了
修复后有个副作用需要说清楚:CellGroup 是 role="list",而 clickable 的 Cell 现在是 role="button"——一个 list 里混进了 button。
按 ARIA 规范,list 的直接子项应该是 listitem。混入 button 后,读屏软件可能不再报告"列表共 2 项",而是把这两个当成独立按钮。
更规范的做法是:listitem 里再包一层真正的 button。我没这么做,原因是:
- 会多一层 DOM,Cell 的样式(flex 布局、hover 态、分割线)都要跟着调整;
- Cell 支持
icon/value/right多个插槽,包一层按钮后插槽的点击区域会变复杂; - 当前场景下「列表」的分组意义弱于「可点击」——两行设置项,用户更需要知道能点,而不是"这里有 2 项"。
这是个有意识的取舍,不是漏掉的细节。如果哪天 CellGroup 用在长列表里(比如联系人),我会改成 listitem + button 的结构。
Grid 是同一个问题:它早就有 role="button",但没 tabindex——读屏软件会说"按钮",用户按 Tab 却永远摸不到它。这比完全没写 role 更糟:给了一个做不到的承诺。
实测:键盘真的能用了
带断言的真机验证(不是看截图):
| 组件 | 聚焦 | 按 Enter 后 |
|---|---|---|
| Cell(退出登录) | document.activeElement === cell 为 true | 回显变成「点了:退出登录」 |
| Grid(首页格子) | 聚焦成功 | 回显变成「点击了:首页」 |
Grid 的 item-click 会带出 (item, index) 两个参数,所以键盘触发和鼠标触发拿到的载荷完全一致——这点很重要,否则业务侧还得写两套处理。
读屏实际会念成什么
role="button" 之后,读屏软件会念的内容来自元素的可访问名称。没有 aria-label 时,它会退化成「内部文本拼接」,于是一行 Cell 可能被念成:
「修改密码 上次修改于 3 个月前 按钮」
信息没错,但很啰嗦——label 是补充说明,不该混进按钮名。更讲究的写法是显式给名称、把补充说明降级成描述:
<div role="button" aria-label="修改密码" aria-describedby="pwd-hint">
<span class="rn-cell__title">修改密码</span>
<span class="rn-cell__label" id="pwd-hint">上次修改于 3 个月前</span>
</div>
这样读屏念「修改密码,按钮」,再补一句描述。我没在这次改动里加,因为它需要给每个 Cell 生成唯一 id(useId),属于独立的一轮工作——已记进待办。
测试和真机验证,各自能证明什么
补的 3 个用例用的是:
await wrapper.trigger("keydown", { key: "Enter" });
expect(wrapper.emitted("click")).toHaveLength(1);
但 trigger 只派发一个 DOM 事件,它不会触发浏览器的默认行为。所以单元测试能证明的是「按键处理函数被调用了」,不能证明「真实浏览器里按 Enter 真的会激活」。
这就是我为什么又去真机跑了一遍:聚焦元素、keyboard.press("Enter")、读回显文本。两者分工很明确:
| 验证方式 | 能证明 | 不能证明 |
|---|---|---|
| 单测(trigger) | 逻辑分支正确、事件会 emit | 浏览器真实行为、焦点可达性 |
| 真机(Tab + Enter) | 确实能聚焦、确实能激活 | 边界分支是否全覆盖 |
只写单测会漏掉「tabindex 写错导致根本聚焦不到」这类问题;只跑真机则无法覆盖各种组合。两层都做,成本也就几分钟。
为什么这次不直接换成 <button>
一个自然的想法:既然是按钮,改成原生 <button> 不就行了?
推演一下代价:
- 列表语义会被破坏。CellGroup 是
role="list",子项应为listitem;换成button后,读屏不再把它当列表的一项,而是一个孤立按钮。 - 默认样式要全部覆盖。
button自带边框、背景、字体、内边距、text-align,都要一行行 reset,跨浏览器还各有差异。 - 嵌套交互元素受限。Cell 内部可能有开关、箭头、自定义插槽;
button里不能再放可交互元素(HTML 规范不允许 interactive content 嵌套)。
所以这里选了「role 动态切换 + 手动补 tabindex 与键盘」:clickable 为 false 时保持 listitem(纯展示,不该进 Tab 序列),为 true 时变成 button。语义跟着行为走,而不是组件名走。
一个反例:Title 不需要这些
同批的 Title 用 level 渲染成 h1~h4:
const colProps = `h${props.level}`; // 实际是 :is="${`h${level}`}"
原生标题标签自带语义,不需要 role、不需要 tabindex(标题本来就不该被 Tab 聚焦,而是通过读屏的标题导航跳转)。实测四个层级的标签与类名一一对应:H1/rn-title--1、H3/rn-title--3、H4/rn-title--4。
ellipsis 也是实测过的真截断:scrollWidth 451px、clientWidth 260px,计算样式 overflow: hidden + white-space: nowrap + text-overflow: ellipsis。
同批还量了两个别的
Col 的三种偏移:offset 走 margin-left(实测 151.5px,position: static,会推开后面的元素);push/pull 走 position: relative + left/right(实测 ±323px,margin-left: 0,只改画面不改文档流)。所以两列可以「视觉换位、DOM 顺序不变」——读屏顺序仍是 A 然后 B。
TabBar 的 change 有前提:只有值真的变了才发。
emit("update:modelValue", item.name);
if (item.name !== props.modelValue) emit("change", item.name);
重复点当前项不会触发 change——避免了「点一下当前 tab 也上报一次埋点」的误报。
一段能自查的代码
检查一个交互元素到底能不能被键盘用,其实不用读代码,控制台里几行就够:
const el = document.querySelector(".rn-cell");
// 1) 能聚焦吗
el.focus();
const focusable = document.activeElement === el;
// 2) 有语义吗
const role = el.getAttribute("role");
// 3) 键盘能激活吗(手动按 Enter 后看事件)
el.addEventListener("click", () => console.log("clicked"), { once: true });
// 4) 在 Tab 序列里吗
const inTabOrder = el.tabIndex >= 0;
console.log({ focusable, role, inTabOrder });
三段输出对应三件事:focusable 说明能不能摸到,role 说明读屏怎么念,inTabOrder 说明 Tab 会不会路过它。
我这次就是先跑了这段,看到 { focusable: false, role: "listitem", inTabOrder: false } 才确认问题——比读十遍模板快。
顺带说,tabIndex 这个属性有个坑:el.tabIndex 永远是数字(不可聚焦时是 -1),而 el.getAttribute("tabindex") 才是 null。判断"有没有显式设置"要用后者,判断"能不能聚焦"用前者(>= 0)。
下一个同类问题:TabBar
顺着这条线查了同批的 TabBar,发现它一个 ARIA 属性都没有——容器没有 role="tablist",每个 tab 没有 role="tab" 和 aria-selected,图标只标了 aria-hidden。
它现在能用,是因为它长得像 tab、点起来像 tab;但读屏软件听到的只是三个普通文本。正确的结构应该是:
<div role="tablist" aria-label="主导航">
<button role="tab" aria-selected="true" tabindex="0">首页</button>
<button role="tab" aria-selected="false" tabindex="-1">搜索</button>
</div>
注意 tabindex 是 0 / -1 混用——tablist 的标准做法是「整组只有一个 Tab 停靠点,方向键在组内移动」,也就是 roving tabindex。这比 Cell 那种「每个都能 Tab」复杂一档,需要处理方向键、焦点跟随、aria-selected 同步。
所以这次没动它,只记进了 ROADMAP。先修「完全用不了」的,再修「能用但不标准」的——两者都重要,但优先级不同。
结论
a11y 的验收标准不是「代码里有 role」,而是**「只用键盘,能不能完成同样的事」**。
这次两个组件都是「鼠标能用、键盘不能」:一个压根没写 role,一个写了 role 却没给入口。后者更隐蔽,因为静态检查(包括我自己写的那些 grep)看到 role="button" 就认为过了。
补了 3 个用例把行为钉住(Cell 2 个:语义 + 键盘;Grid 1 个:tabindex + Enter),全量 826 通过。
剩下的 TabBar 还没做 role="tablist" / role="tab" / aria-selected,已记进 ROADMAP——这类"能点但读不出来"的问题,靠读代码很难发现,只能真的用键盘走一遍。