漫游 tabindex 让复合控件内只有一个成员处于Tab序列,其余靠方向键切换
别名: roving tabindex · 活动 tabindex · 单停点复合控件
概念解释
漫游 tabindex(roving tabindex)是实现“Tab 进组、方向键内移”的 DOM 手法:同一时刻只有当前子项的 tabindex 为 0(或保持自然可焦),其余子项设为 -1。方向键把 0 从这个子项挪到下一个,于是 Tab 序列里这个复合控件永远只占一个停点。没有漫游时,每个按钮都是 0,Tab 会把工具栏拆成一串停点——惯例在交互层写对了,实现层仍会漏。
机制
浏览器的 Tab 遍历只看可聚焦性,不理解“这些按钮属于同一工具栏”。把非当前子项设为 -1,等于从链表里摘掉它们,方向键的脚本再调用 focus() 并交换 tabindex。焦点离开组件再回来时,应回到上次活动的子项(记住哪一颗是 0),否则用户会丢失位置。失败常见于动态增删子项后 0 落在已删除节点上,或 React 重渲染把 -1 全部打回 0。漫游解决的是停点数量,不是焦点可见性,也不是阅读顺序是否与画面一致。
怎么研究
用无障碍树或 Tab 探测脚本统计复合控件在静止和移动内部焦点之后的停点个数,期望恒为 1。再测离开再进入是否恢复上次子项。把虚拟列表(只渲染可见行)单独做:滚出视口的节点被销毁时,tabindex 状态机容易丢。自动化可以抓停点,仍要用键盘走一遍确认 focus() 没有被 preventScroll 搞到看不见的项上。
边界
原生 <select>、某些系统菜单自己实现了单停点,不必再套漫游。内容可编辑区域内的 Tab 有插入制表符的语义,不能把编辑器内部格子都做成 roving。触屏点击不经过 tabindex。若复合控件只有一项,漫游没有收益。iframe 或 shadow DOM 边界会切断脚本对子项 tabindex 的控制,需要在边界内各自实现一份。
怎么落地
- 工具栏、选项卡、网格:挂载时只有活动子项为 0,其余 -1;方向键更新这对值并 focus 新项。
- 组件失焦再获焦时恢复上次活动子项,不要每次回到第一项。
- 子项增删后重建活动指针,避免 0 停在已卸载节点。
- 验证:打开无障碍检查器,在工具栏上按方向键若干次,每次停下来只应有一个子项的 tabindex 为 0,Tab 一次应整组离开。