原生 dialog.showmodal() 自动设置 aria-modal="true" 但不保证焦点围栏生效,safari 和旧 edge 中 tab 仍可跳出模态框,必须手动聚焦首元素、拦截 tab 键循环、缓存并恢复触发源焦点。

dialog.showModal() 自动设置 aria-modal 但不等于无障碍就绪
原生 showModal() 确实会自动添加 aria-modal="true"、生成 ::backdrop、响应 Esc,但它**不保证焦点围栏生效**。Safari(全版本)和旧 Edge 中,document.activeElement 可能仍为 body,Tab 键照样跳到页脚按钮——这不是体验问题,是键盘用户根本无法完成操作。
所以不能只依赖属性,必须手动补三件事:
- 打开后立刻调用
firstFocusableElement.focus()(不能等 CSS 动画结束) - 监听
keydown拦截Tab和Shift+Tab,在模态框内手动循环焦点 - 关闭前缓存触发源元素(比如点击的
<button></button>),关闭后用triggerEl.focus()精准恢复,而非document.body.focus()
为什么不能直接给 dialog 加 open 属性或 display: block
<dialog open></dialog> 或 style="display: block" 只会让内容可见,但完全绕过原生模态逻辑:没遮罩层、Esc 不响应、背景仍可点击、焦点不锁定、aria-modal 不生效。更糟的是,Safari 中若把 <dialog></dialog> 嵌套在 <div class="container"> 里,<code>::backdrop 可能压根不渲染。
正确做法只有两条路:
- 确保
<dialog></dialog>是的直接子元素 - 始终用 JavaScript 控制:
dialog.showModal()打开,dialog.close()关闭 - DOM 尚未加载就调用
showModal()?加个DOMContentLoaded或defer脚本
点击 backdrop 关闭 dialog 必须手动监听
showModal() 生成的 ::backdrop 默认**不响应 click 事件**,这是规范行为,不是 bug。很多开发者以为点背景就能关,结果用户卡死。
安全写法是:
dialog.addEventListener('click', e => {
if (e.target === dialog) dialog.close();
});
但 Safari 15.4–16.3 存在兼容问题:有时 e.target 总是 document.body。这时得 fallback 到坐标判断:
- 用
dialog.getBoundingClientRect()获取弹窗区域 - 检查
e.clientX / e.clientY是否落在该区域外 - 注意:别给
<dialog></dialog>设pointer-events: none,这会破坏原生焦点锁定
嵌套 dialog 时焦点上下文必须显式维护
Modal-1 打开后再打开 Modal-2,关闭 Modal-2 后焦点不能回到 body 或 Modal-1 的第一个元素——它必须精确回到 Modal-2 的触发按钮上,否则键盘用户会丢失操作路径。
这意味着每个模态框都要独立管理自己的「焦点栈」:
- 打开时 push 当前
document.activeElement到栈顶 - 关闭时 pop 并 focus 栈顶元素(不是靠 guess 或硬编码)
- 所有可聚焦元素必须是原生可聚焦标签(
button、input、带tabindex="0"的div),避免隐式 tabindex 导致循环失败
原生 <dialog></dialog> 提供了语义基础和默认行为,但真正的无障碍焦点流,最终取决于你是否主动接管、是否逐层维护、是否在 Safari 的边界 case 里留了退路。











