自定义面板必须手动trap focus,因为浏览器不会自动限制Tab键在其中循环;aria-modal="true"仅作语义提示,不影响原生Tab行为,Safari等浏览器仍允许焦点跳出,导致可访问性失败。

为什么自定义面板必须手动 trap focus
因为浏览器不会自动把 Tab 键限制在自定义面板(比如用 div + role="dialog" 实现的弹窗)内部。哪怕加了 aria-modal="true",Safari 和旧版 Chrome 仍会让焦点跳到背景元素,屏幕阅读器用户直接“掉出”面板,可访问性测试直接失败。
这不是 bug,是规范行为:只有原生 <dialog> 的 showModal() 在部分浏览器中做了有限限制,但依然不保证 Tab 循环,且 Safari 完全不处理。所以只要面板不是原生 <dialog>,或需要 WCAG 2.1 AA 合规,就必须手写 trap logic。
怎么获取面板内真正可聚焦的元素
别用 element.matches(':focusable') —— Safari 不支持,会漏掉很多元素。正确做法是组合过滤:
- 先选所有候选:
panel.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])') - 再剔除不可见的:
getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden' - 最后排除
disabled状态的input、button(它们匹配选择器但无法聚焦)
注意:如果面板里有 tabindex="-1" 的容器(比如封装的自定义组件),它本身不能被 Tab 进入,但内部子元素可能可聚焦——得递归检查子节点,不能只看第一层。
立即学习“前端免费学习笔记(深入)”;
首次打开时焦点没落到面板里怎么办
常见错误是只写了 trap 逻辑,却忘了主动触发第一次聚焦。即使监听了 keydown,键盘用户按 Tab 前根本进不来。
必须在面板显示后立即执行:
- 确保 DOM 已挂载且可见(
panel.offsetParent !== null) - 取过滤后的第一个可聚焦元素,调用
.focus() - 如果第一个元素是
tabindex="-1"的容器(如自定义按钮外壳),要穿透进去找它的第一个可聚焦子元素
移动端还要注意:Safari 要求 .focus() 必须由用户手势(如 click)触发才能唤出软键盘,纯 JS 自动调用只会聚焦,不弹键盘。
Tab 循环时 shift+Tab 卡住或跳错的原因
核心陷阱是没区分「首尾元素」和「实际焦点位置」。例如:
- 面板内只有一个
button,first和last指向同一个元素 →shift+Tab时document.activeElement === first成立,但last.focus()不改变焦点位置,导致卡死 - 用了
tabindex="1"强制排序,但 DOM 顺序和 tabindex 冲突,focusables[0]不是视觉上第一个可操作项 - 面板动态渲染后未重新计算
focusables列表,first/last指向已销毁或不存在的节点
真正可靠的循环逻辑只依赖当前焦点元素是否等于数组首尾,而不是 DOM 位置或 tabindex 数值。每次面板内容变化,都得重跑一遍过滤和索引。



















