KAIROS.WORKSPACE
技术宇宙 / ONLINE
返回踩坑地图
前端工程中级5 分钟

aria-modal 拦不住 Tab:命令面板的焦点逃逸

给 dialog 加了 aria-modal=true 就以为模态完成了,键盘用户仍能 Tab 进遮罩后的页面;命令面板焦点陷阱缺失的定位与补齐复盘。

#可访问性#键盘导航#ARIA#模态

现象

命令面板做得很完整:Ctrl+K 唤起、上下键选择、Enter 跳转、Esc 关闭、role="dialog" aria-modal="true" aria-labelledby=… 一个不落。无障碍审计工具也给了高分。但一位键盘测试者反馈:“面板打开后我按 Tab,焦点跑到面板后面的导航栏上了,然后我迷失在一个看不见的面板外面。”

根因

一个常见误解:aria-modal="true" 只是给屏幕阅读器的语义声明,它不会阻止真实键盘焦点移出对话框。原生 <dialog showModal()> 才有浏览器级焦点圈闭;用 role="dialog" 手写的弹层,Tab 顺序仍按 DOM 全局遍历,面板后面的整个页面都在 Tab 序列里。

命令面板 DOM 通常只覆盖页面前部(挂在 body 末尾的浮层),其后紧跟的隐藏内容一旦可聚焦,Tab 就逃逸。同时面板若没把背景内容设 inert/aria-hidden,读屏器用户也会读到“面板 + 整个背景页”。

修复:显式焦点圈闭

监听 keydown,在面板打开时手动把 Tab 兜回内部:

if (event.key === 'Tab') {
  const focusable = [
    ...root.querySelectorAll(
      'button:not([disabled]), a[href], input, [tabindex]:not([tabindex="-1"])',
    ),
  ].filter((el) => el.offsetParent !== null);
  if (!focusable.length) return;
  const first = focusable[0];
  const last = focusable[focusable.length - 1];
  const active = document.activeElement;
  if (event.shiftKey && (active === first || !root.contains(active))) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && (active === last || !root.contains(active))) {
    event.preventDefault();
    first.focus();
  }
}

要点:

  • 每次动态查询可聚焦元素(结果列表会随输入重绘),不要缓存;
  • 处理“焦点已不在 root 内”的兜底(Tab 到边缘时 document.activeElement 可能已逃逸到 body);
  • 换页/关闭时移除该 keydown 监听,避免 SPA 路由下重复叠加。

更彻底的方案

原生 <dialog> + showModal() 自带焦点圈闭、背景 inert、Esc 关闭,是现代浏览器下更省心的选择。用 role="dialog" 手写时,除了焦点陷阱还应把触发器所在的背景容器加 inert(或 aria-hidden="true" + 禁用内部 tabindex),三件事齐全才算“模态完成”。

验证

自动化断言:打开面板 → 连按 12 次 Tab → 断言 document.activeElement.closest('#command-palette') 非空。任何一次逃逸都会被抓到。这个检查已固化进本项目的交互回归套件。