现象
命令面板做得很完整: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') 非空。任何一次逃逸都会被抓到。这个检查已固化进本项目的交互回归套件。