用 roving tabindex 实现无障碍标签页:焦点移动与 ARIA 同步
问题场景
标签页常见的一种错误实现,是让每个标签都保持默认的 tabindex。键盘用户按 Tab 时,会依次穿过所有标签,之后才到达面板内容。标签越多,无效停靠点越多,焦点顺序也容易与当前选中状态脱节。
WAI-ARIA 的 tabs 模式通常采用 roving tabindex:同一组标签中,只有当前标签的 tabindex 为 0,其余为 -1。这样从外部按 Tab 进入时,只会停在当前标签;在标签之间移动则使用方向键。本文示例采用自动激活,即焦点移到哪个标签,就同步选中该标签并显示对应面板。
最小可运行示例
将下面代码保存为 tabs.html,直接用浏览器打开。示例不依赖框架,文案和 id 均为占位值。
<!doctype html>
<html lang='zh-CN'>
<head>
<meta charset='utf-8'>
<title>roving tabindex 标签页示例</title>
<style>
.tabs { max-width: 560px; font-family: system-ui, sans-serif; }
[role='tablist'] { display: flex; gap: 4px; border-bottom: 1px solid #ccc; }
[role='tab'] { padding: 8px 14px; border: 0; background: transparent; cursor: pointer; }
[role='tab'][aria-selected='true'] { border-bottom: 2px solid #2563eb; font-weight: 600; }
[role='tab']:focus-visible, [role='tabpanel']:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
[role='tabpanel'] { padding: 12px 4px; }
</style>
</head>
<body>
<div class='tabs'>
<div role='tablist' aria-label='示例标签页'>
<button role='tab' id='tab-1' aria-controls='panel-1' aria-selected='true' tabindex='0'>概览</button>
<button role='tab' id='tab-2' aria-controls='panel-2' aria-selected='false' tabindex='-1'>配置</button>
<button role='tab' id='tab-3' aria-controls='panel-3' aria-selected='false' tabindex='-1'>日志</button>
</div>
<section role='tabpanel' id='panel-1' aria-labelledby='tab-1' tabindex='0'>
<p>概览面板内容。</p>
</section>
<section role='tabpanel' id='panel-2' aria-labelledby='tab-2' tabindex='0' hidden>
<p>配置面板内容。</p>
</section>
<section role='tabpanel' id='panel-3' aria-labelledby='tab-3' tabindex='0' hidden>
<p>日志面板内容。</p>
</section>
</div>
<script>
const tablist = document.querySelector('[role=tablist]');
const tabs = Array.from(tablist.querySelectorAll('[role=tab]'));
const panels = tabs.map((tab) => document.getElementById(tab.getAttribute('aria-controls')));
function activate(tab, options = {}) {
const index = tabs.indexOf(tab);
if (index === -1) return;
tabs.forEach((item, i) => {
const selected = i === index;
item.setAttribute('aria-selected', String(selected));
item.tabIndex = selected ? 0 : -1;
if (panels[i]) panels[i].hidden = !selected;
});
if (options.focus) tab.focus();
}
tablist.addEventListener('click', (event) => {
const tab = event.target.closest('[role=tab]');
if (!tab || !tabs.includes(tab)) return;
activate(tab, { focus: true });
});
tablist.addEventListener('keydown', (event) => {
const current = event.target.closest('[role=tab]');
if (!current) return;
const index = tabs.indexOf(current);
let next = null;
if (event.key === 'ArrowRight') next = tabs[(index + 1) % tabs.length];
if (event.key === 'ArrowLeft') next = tabs[(index - 1 + tabs.length) % tabs.length];
if (event.key === 'Home') next = tabs[0];
if (event.key === 'End') next = tabs[tabs.length - 1];
if (!next) return;
event.preventDefault();
activate(next, { focus: true });
});
</script>
</body>
</html>
运行与验证
打开页面后,先按 Tab 键进入标签组。此时焦点应落在“概览”上,而不是依次经过三个标签。按 ArrowRight 或 ArrowLeft 可在标签间循环移动焦点,按 Home 或 End 可跳到第一个或最后一个标签。继续按 Tab,应离开标签组并进入当前显示的面板;由于面板设置了 tabindex=0,它会成为一个可聚焦区域。
也可以用开发者工具检查:当前标签的 tabindex 为 0,其余为 -1;aria-selected 只有一个为 true;未显示的 tabpanel 带有 hidden 属性。若用屏幕阅读器测试,应能听到标签名称、选中状态以及对应面板的关系。
实现要点
- 只切换状态,不复制逻辑。activate 函数集中处理 aria-selected、tabindex 和 hidden,避免多个事件处理器各改一部分。
- 方向键需要 preventDefault,否则 ArrowLeft/Right 可能触发页面滚动,Home/End 也可能移动滚动位置。
- 索引循环使用取模运算,ArrowRight 在最后一个标签上回到第一个,ArrowLeft 在第一个标签上回到最后一个。
- 点击标签时也调用 activate 并把焦点放在标签上,保证鼠标操作后继续使用方向键时焦点仍在标签组内。
- 面板的 aria-labelledby 指向对应标签,标签的 aria-controls 指向面板,二者 id 必须唯一。
边界与限制
本示例采用自动激活,适合面板切换成本较低的场景。如果切换面板会触发网络请求、复杂渲染或不可逆操作,可以改为手动激活:方向键只移动焦点和 tabindex,按 Enter 或 Space 时才更新 aria-selected 与 hidden。此时要明确按 Tab 离开再回来时焦点落在哪个标签,并保持行为一致。
示例只处理水平标签组,因此使用 ArrowLeft/ArrowRight。如果标签组是垂直排列,应设置 aria-orientation='vertical',并改用 ArrowUp/ArrowDown;也可以同时支持两组方向键,但要在文档中说明。
如果存在禁用标签,不应让点击或方向键激活它。可以用 aria-disabled='true' 标记,并在计算下一个可聚焦标签时跳过;不要只依赖 disabled 属性,因为不同元素和浏览器对焦点行为的处理并不完全一致。
面板的 tabindex 需要按内容决定。示例面板只有文本,所以设置 tabindex=0,让键盘用户可以进入面板阅读。如果面板内部已经有输入框、按钮或链接,通常应去掉面板的 tabindex,避免多出一个空的 Tab 停靠点。hidden 会同时移除面板及其内部可聚焦元素,不需要再手动修改内部控件的 tabindex。
当一个页面存在多个标签组时,应把查询范围限制在各自容器内,并保证 id 全局唯一。不要在事件处理器里用全局选择器假设只有一个 tablist。最后,键盘操作和屏幕阅读器行为应在目标浏览器与辅助技术组合中实际验证,不能只根据属性是否写对来判断。
- 点赞
- 收藏
- 关注作者
评论(0)