aria-modal="true" 无法拦截 tab 键,仅提示屏幕阅读器,必须配合手动焦点管理实现模态框焦点围栏,否则键盘用户会卡死;需与 role="dialog" 共用,且原生 在 safari 中存在多项兼容性问题。

aria-modal="true" 为什么不能拦住 Tab 键
它只是告诉屏幕阅读器“当前只读这个区域”,对浏览器原生 Tab 行为完全没影响。Safari(全版本)、旧版 Firefox 和部分 NVDA 配置下,aria-modal="true" 被忽略,用户按 Tab 仍会跳到页脚、导航栏甚至 body 上——这不是体验问题,是键盘用户直接卡死。
必须搭配手动焦点管理才能生效,否则等于没做无障碍。
-
aria-modal="true"必须和role="dialog"同时存在,缺一不可 - 不要给
直接加aria-hidden="true",某些读屏器会跳过整页;推荐用包裹主内容的<div id="app"> 控制 <li>如果用了原生 <code><dialog></dialog>,showModal()会自动设aria-modal="true",但 Safari 17.4 之前不支持焦点围栏,仍需 fallback - 获取可聚焦元素:
Array.from(modal.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) - 排除
tabindex="-1"元素(它们可被脚本聚焦,但不参与自然 Tab 流) - 监听
keydown,当document.activeElement是最后一个元素且按Tab时,e.preventDefault()并first.focus();反之亦然 - 首次打开后立刻
firstFocusable.focus(),不要等 CSS 动画结束,否则 Safari 下焦点会卡在body - 打开前缓存触发元素引用,比如
const triggerBtn = event.target - 关闭后检查该元素是否仍存在且可聚焦,再调
triggerBtn.focus() - 若触发源是动态生成的(如表格行内按钮),用
data-modal-trigger-id绑定关系,配合事件委托 - 避免
setTimeout(() => ..., 0),Firefox 中可能失效;改用queueMicrotask(() => triggerBtn.focus()) -
<dialog open></dialog>或style="display: block"完全绕过模态逻辑:无遮罩、Esc 不响应、背景可点、焦点不锁 - 正确姿势只有两个:
dialog.showModal()打开,dialog.close()关闭;且<dialog></dialog>必须是的直接子元素 - 点击遮罩层关闭需手动监听:
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); }) - 嵌套
<dialog></dialog>时,每层必须独立维护可聚焦元素列表和焦点索引,不能靠浏览器自动管理
怎么手动实现 Tab 键循环陷阱
核心是捕获 Tab 和 Shift+Tab,在模态框内手动跳转焦点,而不是依赖伪类或浏览器行为。
别用 querySelectorAll('*:focusable') —— 这个伪类根本不存在,浏览器不支持。
关闭后焦点必须回到触发源,不是 document.body
很多实现直接调 document.body.focus(),结果屏幕阅读器从头朗读页面,用户完全丢失上下文。这违反 WCAG,也违背真实操作流。
<dialog></dialog> 的兼容性坑点
原生 <dialog></dialog> 看似省事,但实际落地时 Safari 是最大变量:它不渲染 ::backdrop、不拦截 Tab、点击遮罩层默认不关闭——这些都不是 bug,是规范未覆盖的现实。











