aria-modal="true" 仅是语义标记,无法限制Tab导航,必须手动实现焦点围栏:获取可聚焦元素、捕获Tab键、循环跳转焦点、精准恢复触发源焦点,并配合role="dialog"与aria-hidden="true"使用。

aria-modal="true" 本身不能限制 Tab 键跳出
它只是语义标记,告诉屏幕阅读器“当前只应操作这个区域”,对浏览器原生 Tab 导航行为完全无影响。Safari(所有版本)、旧版 Firefox、部分 NVDA 配置下,aria-modal="true" 被直接忽略——用户按 Tab 仍会跳到页脚、导航栏甚至 body 上,键盘用户卡死是真实风险,不是体验问题。
必须手动实现焦点围栏(focus trap)
核心逻辑是监听 keydown,捕获 Tab 和 Shift+Tab,在模态框内手动跳转焦点。关键点:
- 用
Array.from(modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))获取可聚焦元素列表(别用不存在的*:focusable伪类) - 打开后立刻调
firstFocusable.focus(),不要等 CSS 动画结束,否则 Safari 下焦点可能卡在body - 监听
modal的keydown:当e.key === 'Tab'时,e.preventDefault(),再根据当前聚焦元素索引和是否按住 Shift,跳到下一个或上一个可聚焦项 - 越界时绕回:焦点在最后一个元素按 Tab → 跳第一个;在第一个按 Shift+Tab → 跳最后一个
关闭后焦点必须精准回到触发源
很多实现直接调 document.body.focus(),结果屏幕阅读器从头朗读页面,用户彻底丢失上下文。正确做法:
将 Markdown 文章转换为微信公众号兼容的纯 HTML 格式。当用户要求"转成微信格式"、"生成公众号文章 HTML"、"排版到微信草稿箱"、"微信粘贴格式"时触发。输出带内联样式的纯 HTML,微信编辑器可直接渲染,无需额外适配。支持标题、段落、列表、粗体、行内代码、表格、引用块、代码块、配图的完整转换。
- 打开前缓存触发按钮引用:
const triggerBtn = event.target - 关闭时检查该元素是否仍在 DOM 中且可聚焦(
triggerBtn.offsetParent !== null && triggerBtn.tabIndex >= 0),满足则调triggerBtn.focus() - 若触发源是动态生成的(如表格行内按钮),用
data-modal-trigger-id绑定关系,配合事件委托 - 避免
setTimeout(() => ..., 0),Firefox 中可能失效;改用queueMicrotask(() => triggerBtn.focus())
role="dialog" 和 aria-hidden 必须配合使用
aria-modal="true" 单独无效,必须和 role="dialog" 同时存在,缺一不可。同时,为兼容 Safari 和旧版辅助技术,还需双保险:
- 给包裹主内容的容器(如
<div id="app">)动态添加 <code>aria-hidden="true",而不是直接设在body上(某些读屏器会跳过整页) - 关闭模态框时务必移除
aria-hidden="true",否则后续内容无法被读取 - 如果用了原生
<dialog></dialog>,showModal()会自动设aria-modal="true",但 Safari 17.4 之前不支持焦点围栏,仍需 JS fallback
真正起作用的从来不是 aria-modal 这个属性本身,而是你是否在它背后补上了那几行关键的 focus()、preventDefault() 和 queueMicrotask()。漏掉任意一环,键盘用户就可能卡死。










