role="tablist"仅提供语义,交互逻辑须手写JS实现:键盘导航(Arrow/ Home/End)、焦点管理、aria-selected同步、aria-controls/aria-labelledby双向绑定、触摸后显式focus()等均需开发者处理。

role="tablist" 本身不自动实现选项卡交互
很多人以为只要给容器加 role="tablist",再套几个 role="tab" 和 role="tabpanel" 就算完成了——其实这只是语义层的起点。WAI-ARIA 不接管行为逻辑,键盘导航(Tab/Arrow)、焦点管理、选中状态同步、aria-selected 切换、aria-controls/aria-labelledby 关联,全得手写 JS 控制。
常见错误现象:Tab 键跳过所有 tab 元素(因为默认不可聚焦)、按方向键无响应、屏幕阅读器读不出“已选中”、切换后内容区未获得焦点。
- 每个
tab必须有tabindex="0"或tabindex="-1"(初始聚焦用 0,非激活态切为 -1) -
tablist容器不能设tabindex,否则会多出一个无意义焦点停靠点 -
tabpanel默认应设hidden或aria-hidden="true",仅当前激活项设aria-hidden="false"
必须手动绑定键盘事件:ArrowLeft/ArrowRight + Home/End
WAI-ARIA Authoring Practices 明确要求水平排列的 tablist 支持左右方向键循环切换,Home 跳首项、End 跳末项。浏览器不会自动处理这些。
实操建议:
立即学习“前端免费学习笔记(深入)”;
- 监听
tablist的keydown事件,而非每个tab单独监听(避免事件重复绑定) - 方向键需阻止默认行为(
event.preventDefault()),否则可能触发页面滚动 - 循环逻辑要小心边界:从最后一项按 → 应回到第一项,且焦点必须真实移到对应 DOM 节点(
.focus()) - 不要依赖
document.activeElement判断当前 tab —— 用户可能用鼠标点击,焦点已在 tab 上,但 JS 状态未更新
aria-selected 和 aria-controls 必须严格配对
这两个属性是屏幕阅读器理解“哪个 tab 控制哪个 panel”的唯一依据。漏掉或值不匹配,会导致 NVDA/JAWS 无法关联内容,用户听到的只是孤立的标签和空白区域。
关键细节:
-
tab上的aria-controls值,必须与目标tabpanel的id完全一致(区分大小写、不可含空格) -
tabpanel上的aria-labelledby应指向对应tab的id,形成双向可溯链接 -
aria-selected="true"只能出现在一个tab上,且必须与当前显示的tabpanel严格对应;JS 切换时,这两处状态要同时更新 - 避免用 class 名(如
is-active)替代aria-selected—— 屏幕阅读器完全忽略 class
移动端触摸和键盘焦点的冲突容易被忽略
在 iOS Safari 或 Android Chrome 上,触摸点击 tab 后,元素虽被激活,但焦点可能未真正落在该元素上(尤其当用了 preventDefault 或 CSS user-select: none)。结果就是:手指点了,视觉变了,但键盘操作仍停留在旧 tab。
解决路径很直接:
- 触摸事件(
touchend)触发后,显式调用tabElement.focus() - 确保
tab元素没有outline: none且未被:focus-visible隐藏(否则键盘用户看不到焦点位置) - 如果用了
pointer-events: none做遮罩或动画过渡,会彻底阻断焦点捕获,必须改用visibility或opacity
最麻烦的其实是嵌套:比如 tab 内部有表单控件,用户填完按 Tab 想跳到下一个 tab —— 这时你得判断焦点是否已移出当前 panel,再决定是否接管并跳转。这种边界情况,文档很少提,但真实项目里几乎必踩。



















