弹层打开后tab键“跑出”模态框,根本原因是未实现焦点陷阱:必须主动拦截tab/shift+tab并循环聚焦弹层内可聚焦元素,而非依赖css隐藏或aria-modal属性。

弹层打开后 Tab 键为何“跑出”模态框?
这是最常被忽略的可访问性断裂点:用户按 Tab 进入弹层后,焦点继续循环到背景页面的可聚焦元素(如导航栏链接、表单控件),导致键盘用户彻底迷失上下文。屏幕阅读器也会同步朗读背景内容,破坏当前任务流。
根本原因不是 JavaScript 写错了,而是没做「焦点陷阱(focus trap)」——即主动拦截 Tab/Shift+Tab,并将焦点限制在弹层内所有可聚焦元素之间。
- 必须监听
keydown事件,捕获Tab键;不能只靠 CSSvisibility: hidden或display: none隐藏背景——这些不影响 DOM 可聚焦性 - 弹层内首个可聚焦元素(通常是第一个
button或input)需在showPopup()中显式调用.focus() - 当焦点到达最后一个可聚焦元素再按 Tab 时,应立即把焦点设回第一个;Shift+Tab 则反向处理
- 注意:
document.activeElement在 Shadow DOM 内可能失效,建议用shadowRoot.querySelector('[tabindex], button, [href], input, select, textarea, [contenteditable]')动态收集
Escape 键关闭弹层时,焦点该回到哪里?
不是“随便 focus 回上一个元素”,而是必须回到触发弹层的源按钮(trigger button)。否则键盘用户无法知道“我从哪儿点进来的”,会怀疑自己误操作或页面出错。
关键在于保存触发源引用,而不是依赖 document.activeElement ——后者在弹层打开前可能已被其他脚本修改。
- 在绑定弹层打开逻辑时,就记下触发按钮:
triggerButton.addEventListener('click', () => { openModal(triggerButton); }); -
openModal(trigger)函数内保存this.trigger = trigger(或用 WeakMap 存储) - Escape 关闭后,执行
trigger.focus(),并确保该元素有tabindex="0"(若原是divonclick) - 如果触发源已销毁(如动态渲染后移除),fallback 到页面主内容区域:
document.querySelector('main')?.focus()
弹层内按钮为什么按空格/Enter 没反应?
自定义弹层按钮若用 div + onclick 实现,即使加了 role="button",也仅是“告诉屏幕阅读器它像按钮”,不等于它就是按钮。原生交互能力(空格/Enter 触发、disabled 响应、焦点管理)全无。
- 必须用语义化
button元素,而非div或span;若样式受限,可用button+ CSS 重置外观 - 禁用状态要同时设
disabled属性和aria-disabled="true"(部分旧读屏器依赖后者) - 避免在按钮内嵌套
div或p——这会破坏button的默认文本可访问性;改用span包裹图标和文字 - 如果必须用自定义元素(如
x-close-button),则需手动监听keydown并判断event.key === 'Enter' || event.key === ' '
为什么用了 aria-modal="true" 还被读屏软件跳过?
aria-modal="true" 是 WCAG 2.1 要求的必要属性,但它本身不生效——前提是弹层必须满足两个硬性条件:一是有 role="dialog" 或 role="alertdialog",二是必须有可聚焦的子元素(且至少一个获得初始焦点)。
- 漏掉
role就等于没声明它是模态对话框,aria-modal被完全忽略 - 弹层容器若没设
tabindex="-1",则无法通过.focus()获得焦点,导致初始焦点失败 - 屏幕阅读器对
aria-labelledby和aria-describedby的解析高度依赖 DOM 顺序:标题元素必须出现在弹层内容最前面,且 ID 必须真实存在 - 不要给弹层外的元素加
aria-hidden="true"——现代读屏器优先信任aria-modal,手动隐藏反而干扰焦点管理逻辑
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!











