dialog 标签本身不等于无障碍弹窗,需手动实现聚焦首元素、拦截tab循环、关闭后恢复触发源焦点三件事;必须为body直接子元素,否则backdrop不渲染或焦点失控;showmodal()后须立即手动聚焦首个可聚焦元素;tab循环需手写keydown监听;点击backdrop和esc关闭均需手动处理并恢复焦点。

dialog 标签本身不等于无障碍弹窗,Safari 全版本和旧 Edge 中焦点围栏完全失效,必须手动补足三件事:聚焦首元素、拦截 Tab 键循环、关闭后精准恢复触发源焦点。
dialog 必须是 body 的直接子元素,否则 backdrop 不渲染或焦点失控
在 Safari(包括 iOS/macOS 所有版本)中,如果 dialog 嵌套在 <div class="container"> 或任何非 <code>body 的父容器里,::backdrop 可能压根不出现——用户点不到背景,也无法用点击遮罩关闭。
实操建议:
- 动态创建时,务必用
document.body.appendChild(dialogEl)挂载,不要塞进某个 wrapper div - 服务端渲染或静态 HTML 中,确保
<dialog></dialog>标签出现在下第一层,不包在<main></main>、<div id="app"> 等内部容器中 <li>检查 DOM 结构:打开开发者工具,确认 <code>dialog元素的parentNode是document.body - 调用
dialog.showModal()后,立即用requestAnimationFrame(() => firstFocusable.focus())聚焦首个可聚焦节点(如<button></button>、<input>) - 别依赖
autofocus属性:showModal()不触发重挂载,autofocus在此时无效 - 获取首个可聚焦元素推荐用
dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),过滤掉tabindex="-1" - 为每个
dialog单独绑定keydown监听器,仅作用于该弹窗根节点(避免全局document.addEventListener) - 捕获
Tab和Shift + Tab:当焦点在最后一个可聚焦元素上再按 Tab,event.preventDefault()并 focus 第一个;反之亦然 - 缓存可聚焦元素列表,每次打开弹窗时重新计算(DOM 可能动态变化)
- 关闭弹窗前,必须记录触发按钮(比如
openBtn),关闭后检查它是否还存在且offsetParent !== null,再调triggerEl.focus() - 监听
dialog的click事件,用event.target === dialog判断是否点中 backdrop 区域(注意:不是event.target === dialog.backdrop,因为 backdrop 是伪元素) - 或者监听
dialog的pointerdown,再用event.composedPath()[0] === dialog判断原生点击目标 - ESC 关闭后,同样要恢复触发源焦点——不是
document.body.focus(),而是之前缓存的那个按钮
不能只调 showModal(),必须立刻手动聚焦第一个可聚焦元素
showModal() 不会自动把焦点移到弹窗内,尤其在 Safari 和旧版 Firefox 中,document.activeElement 很可能还是 body。用户按 Tab 会直接跳到页脚按钮,而不是弹窗里的确认按钮。
实操建议:
Tab 键循环必须手写监听,inert 和 aria-modal 都不解决 Safari 焦点逃逸
inert 属性在 Safari 15.3+ 才支持,且它只禁用交互,不参与焦点流控制;aria-modal="true" 在 Safari 和旧 Firefox 中被忽略,屏幕阅读器仍读背景内容。
实操建议:
点击 backdrop 关闭必须手动监听,::backdrop 默认不响应 click
这是规范行为,不是 bug。showModal() 生成的 ::backdrop 本身没有事件监听能力,很多开发者误以为“点背景就关”,结果键盘用户卡死在弹窗里。
实操建议:
最易被忽略的一点:嵌套多个 dialog 时,每层都得独立维护焦点状态和 Tab 监听器,浏览器不会帮你做上下文隔离。上层弹窗的焦点逻辑一旦没暂停,用户按 Shift+Tab 就可能直接跳回上层按钮,彻底破坏操作流。











