给标签页加 roving tabindex,整组反而键盘进不去了

6 个标签页,键盘要按 5 次 Tab 才能穿过去------这还算「能用」。改成 roving tabindex 之后,整组只剩 1 个停靠点,方向键也能走了。

但上线后打开文档站,整组标签一个都聚焦不了 。问题不在键盘逻辑,而在一个没人写进契约的隐藏前提:activeName 必须真的存在于 items 里。从「加 tabindex」到「整组不可达」,只隔了一个默认值。下面按「改造 → 上线 → 翻车 → 修回来」的顺序完整写一遍。

改造前:5 个停靠点,0 个方向键

测量方式:同一份 6 标签组件,分别加载 npm 0.2.26 与本地 0.2.28。状态行每 120ms 打印激活名、tabindex=0 数量、aria-controls 数量、panel 的 aria-labelledby / tabindex、当前焦点。按键全部用真实键盘事件,先读状态断言,再截图。

6 个标签(其中一个禁用、一个可关闭),改造前的实测数据如下:

指标 0.2.26
Tab 停靠点 5 个(每个可用标签一个)
穿过标签栏按几次 Tab 5 次 + 1 次到外部按钮
焦点在标签上按 → 激活项不变
Home / End 无反应
aria-controls 0 个
tabpanel aria-labelledby / tabindex 无 / 无
从激活标签按 Tab 落到下一个标签,永远进不了内容区
Delete 关闭可关闭标签 无反应(关闭图标只能鼠标点)

WAI-ARIA 的 tablist 模式要求两件事:整组只有一个 Tab 停靠点,方向键在标签间移动;标签与面板通过 aria-controls / aria-labelledby 互相指向。原来的实现只做了 role="tab" 和 aria-selected,其余都没做。

为什么 5 个停靠点是个问题

按一次 Tab 穿过一个可聚焦元素,是键盘用户的心智模型。标签栏有 6 个标签时,想从页面顶部到内容区要先经过 5 个标签;页面里若还有两个标签栏,成本线性叠加。WAI-ARIA 的 tablist 模式因此规定整组一个停靠点:进入标签栏一次,之后用方向键。这不是「少按几次」的微优化,而是让「标签栏」在键盘上表现为一个控件,而不是 5 个按钮。

源码:tabindex 根本没写

模板里每个标签都是普通 button:

html 复制代码
<button role="tab" :aria-selected="item.name === activeName" @click="onSelect(item)">

button 默认可聚焦,于是 6 个标签就有 5 个停靠点。tablist 上也没有 @keydown,方向键自然没人处理。面板只有 role="tabpanel",没有 id、没有 aria-labelledby,也没有 tabindex------即使想用 Tab 进内容区也进不去。

关闭图标更微妙:它是一个 <span role="button">,套在 <button> 里面。嵌套交互元素本身就不合法,而且没有 tabindex,键盘永远到不了。

还有一处连带影响:指示条靠测量 .rn-tabs__tab--active 的 offsetLeft/offsetWidth 定位。没有激活项时 inkReady=false,整条指示条直接透明------所以 0.2.27 的问题不只是键盘,视觉上「当前在哪一页」也同时消失了。键盘与视觉共用一个状态,这种耦合在改造前不容易被注意到。

修法一:roving tabindex + 方向键

html 复制代码
<button
  role="tab"
  :tabindex="item.name === activeName && !item.disabled ? 0 : -1"
  @keydown="onNavKeydown"
>

onNavKeydown 挂在 tablist 上,处理四件事:

  • ← / →:在可用标签间循环,跳过 disabled;
  • Home / End:跳到第一个 / 最后一个可用项;
  • 移动即激活(automatic activation),并 focus({ preventScroll: true }) 后 scrollIntoView,长标签栏也能跟到;
  • Delete:关闭当前可关闭的标签,标签上补 aria-keyshortcuts="Delete"。

关闭图标改成 aria-hidden="true",只服务鼠标/触屏;键盘走 Delete。这样 button 里不再嵌套一个假按钮。

实测:Tab 序列从 标签 ×5 → 外部按钮 变成 标签 → 面板 → 外部按钮,停靠点 5 → 1。

方向键处理里有两件事必须按顺序:先改激活态,再移焦点。激活态触发 aria-selected、aria-labelledby、指示条三处更新;焦点用 focus({ preventScroll: true }),防止浏览器为了露出按钮把整个页面滚一下;随后 scrollIntoView({ inline: "nearest" }) 只滚标签栏自身。反过来的话,焦点先落在旧位置,读屏会先念旧标签。

修法二:把标签和面板配对

html 复制代码
<button role="tab" :id="tabId(index)" :aria-controls="panelId">
<div role="tabpanel" :id="panelId" :aria-labelledby="activeTabId" tabindex="0">

id 用 useId() 生成,SSR 与客户端一致。面板 tabindex="0" 解决「内容区里没有可聚焦元素时键盘到不了」的问题;焦点环改用主题色,不再是浏览器默认的黑框。

实测:aria-controls 0 → 6/6 ,激活标签的 aria-labelledby 随切换同步,从激活标签按一次 Tab 就落在面板上。

我以为是 A,实测是 B

我以为把 tabindex 改成 active ? 0 : -1 就结束了。

0.2.27 发布后,我打开文档站的 Tabs 页准备截图。结果状态行是:

ini 复制代码
选中数=0  tabindex=0 数=0  全部-1=是

四个标签,没有一个 aria-selected="true",也没有一个 tabindex="0"。键盘按 Tab 直接跳过整组------比改造前更糟。

原因是文档示例的初始值:

html 复制代码
<Tabs v-model="tab1" :items="[{ name: 'intro' }, { name: 'spec' }, ...]" />

而 tab1 的默认值是 "a"。activeName 是 "a",items 里没有 "a",于是每个标签都算出 tabindex=-1。改造前所有标签默认可聚焦,这个不匹配只是「没有高亮」;roving 之后,它变成「整组不可达」。

**roving tabindex 有一个隐藏前提:必须真的存在一个激活项。**而 modelValue 是外部输入,可能来自 URL、localStorage、后端,组件不能假设它一定匹配。

修法是让激活项回落到可用范围:

ts 复制代码
const inner = ref(nameExists(props.modelValue) ? props.modelValue : firstEnabledName());

watch(() => props.items, () => {
  if (!nameExists(inner.value)) inner.value = firstEnabledName();
}, { deep: true });

第二个 watcher 处理动态 items:关闭当前标签、权限变化导致 items 减少时,激活项也要拉回来,否则整组再次变成 -1。

补了 2 条用例(modelValue 不匹配、激活项被移除),单测从 926 → 933。

为什么不能只改示例

实测 0.2.27 的文档页:4 个示例全部 selected=0、tabindex=-1;0.2.28 回落之后,示例里的「商品介绍 / 全部订单 / 今日 / 热门推荐」四个首项都恢复为 active 且 tabindex=0。

把示例的 tab1 改成 "intro" 只修了文档,线上真实项目里仍有三种常见的不匹配:

  1. URL 里的 ?tab=xxx 指向一个已被删除的标签;
  2. 本地存储恢复的页签状态过期;
  3. 权限列表变化后,原来激活的标签被移出 items。

组件不应该在这种情况下「静默失去键盘可达性」。这三种情况的共同点是:把「外部状态」当成了「内部不变量」。state 从外面来,组件能做的不是假设它正确,而是在不合法时给出一个确定的退化行为。回落不是兜底技巧,而是 roving 模式的安全前提。

换成另一种实现会怎样

方案 Tab 停靠点 方向键 问题
全部可聚焦(原方案) 每个标签 1 个 无 6 个标签 5 次 Tab;方向键缺失
roving tabindex(现方案) 1 个 ←/→/Home/End 依赖激活项存在,必须做回落
aria-activedescendant 1 个(容器) 自定义 tablist 模式期望 DOM 焦点落在 tab 上,读屏兼容性更差
手动激活(方向键只移动、Enter 才切换) 1 个 需要两次操作 对 2--5 个标签的轻量场景过重

第三行值得展开:activedescendant 适合 combobox 那种「输入框保持焦点」的控件;tablist 没有输入框,直接用 DOM 焦点更简单,也更符合读屏预期。第四行的手动激活适合标签内容很重、切换有成本的场景;本项目没有这个需求,自动激活更直接。第二行是本次采用的方案,代价就是必须维护「激活项始终有效」这个不变量------所以回落逻辑和 roving 必须同一批上线。

边界

  • 禁用项 :方向键跳过,Home/End 也落在可用项上;全部禁用时直接 return。
  • 只有一个标签 :循环仍成立,←/→ 都停在原地,不会出现 -1。
  • 没有内容面板 (只给 items、不给具名插槽):不渲染 panel,也不输出 aria-controls,避免指向不存在的 id。
  • 关闭当前标签:items 变化触发回升,激活项回到第一个可用标签;焦点此时在已移除的按钮上,会落回 body,需要业务层决定把焦点放到新激活标签还是关闭按钮------组件不越权。后台多页签通常移到新激活标签,浏览器式页签则移到相邻标签。

怎么验证的

两个构建跑同一套 14 条断言:0.2.26 组按「预期坏」(5 个停靠点、方向键无效、无 aria、Delete 无效),0.2.28 组按「应该好」逐条断言;另外用 0.2.27 的 dist 单独复现「modelValue 不匹配」的中间态(选中 0、全部 tabindex=-1)。每组先读状态断言,再截图,共 14 张改造前后的对照图。

结果

两个版本:

版本 内容
0.2.27 roving tabindex、←/→/Home/End、tab/panel aria 配对、panel 可聚焦、Delete 关闭
0.2.28 激活项回落:modelValue 不匹配 / items 动态变化时拉回首个可用项
  • 单测 926 → 933(+5 键盘/aria/Delete,+2 回落)
  • 真机断言 14/14 :Tab 序列 5 → 1、aria-controls 0 → 6/6、Delete 后 6 → 5、方向键从 overview → detail
  • 中间状态也留了证据:0.2.27 + 不匹配的 modelValue,选中数 0、全部 tabindex=-1

结论

roving tabindex 不是「把 tabindex 改成 0/-1」这么简单,它引入了一个新的状态约束。三条规则:

  1. roving 之前先保证有激活项:外部 modelValue 不可信,不匹配就回落到首个可用项;
  2. items 动态变化要重新校验激活项,否则关闭当前标签会让整组再次不可达;
  3. tab 与 panel 必须双向配对 ,面板补 tabindex=0,让「没有可聚焦内容」的面板也能被键盘到达。

第 1 条最难在代码评审里发现:所有单元测试都用匹配的 modelValue,组件表现完美;只有文档示例里那个「默认值对不上」的组合,才会把整组键盘可达性打回零。它不是代码风格问题,而是 roving 的状态前提:没有激活项,就没有停靠点。回落逻辑本身只有几行,但它把 roving 从「示例里能用」变成「任何输入下都可用」。


组件解剖 · 系列目录

  • Table:虚拟滚动的三个硬约束
  • Radio / RadioGroup:ARIA 与方向键选中
  • Transfer:三态复选框的三个 bug
  • DatePicker:42 格日期网格的四个边界
  • Select:可搜索下拉的键盘、ARIA 与滚动
  • Carousel:自动播放的暂停机制与播报时机
  • Tabs(本篇):roving tabindex 的隐藏前提与面板配对

在线示例:ui.ruanno.top/components/... 源码:gitee.com/ruanno-top/...

相关推荐
仨孩子的爹28 分钟前
CSS打印样式:字体族与行高在纸上的对照实测
vue.js
OpenTiny社区3 小时前
AI Agent场景组件库 TinyRobot v1.0 正式发布:一站式打造您的AI助手
vue.js·github
ruanno_top20 小时前
轮播每 3 秒自己翻页:键盘停不下来,读屏也不知道换了页
vue.js
1-1=020 小时前
Vue 3 模板语
前端·javascript·vue.js
Java小卷1 天前
面向 Schema 编程,Vue 3 页面级渲染引擎!轻松玩转各场景 Validation!
vue.js·低代码
FungLeo1 天前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装
FungLeo1 天前
成为全栈·Vue 管理后台篇·Vue Router如何守住多角色后台边界
前端·javascript·vue.js·rbac·vue router·路由守卫·前端权限
FungLeo1 天前
成为全栈·Vue 管理后台篇·分页只显示第一页:从接口total 定位到前端修复
前端·vue.js·状态模式·分页·naive ui·服务端分页·前端问题排查