焦点陷阱应优先使用 focusin 事件并预收集可聚焦元素,label 必须正确关联 input,dialog 需确保可聚焦项存在且添加 aria-modal,:focus-visible 替代 outline 保障键盘导航可见性。

focusin 事件比 keydown 更可靠,但必须配合预收集可聚焦元素
监听 Tab 键的 keydown 容易漏掉 Shift+Tab、Safari 的 keyCode 不一致、以及屏幕阅读器绕过键盘事件的情况。真正健壮的焦点陷阱靠的是 focusin——它能捕获任何方式进入容器的焦点(键盘、鼠标点击、屏幕阅读器跳转、focus() 调用),且冒泡特性正好用来判断“是否刚进容器”或“是否要逃逸”。
但光监听不够:必须提前用 querySelectorAll 把所有有效可聚焦项存下来,否则每次 focusin 都查 DOM,异步渲染场景下可能拿到空数组。
-
button、input、select、textarea默认可聚焦,不用加tabindex -
[tabindex]:not([tabindex="-1"])才算有效——tabindex="-1"只用于程序聚焦,不能参与 Tab 循环 - 若弹窗内容异步加载,得等 DOM 渲染完成再收集,
requestAnimationFrame或dialog:open事件更稳,别在showModal()后立刻查
label 关联不是装饰,而是屏幕阅读器读取字段语义的唯一依据
没用 for/id 关联或没把 input 包在 label 里,等于直接对屏幕阅读器说“这行字和下面那个框没关系”。用户听到的可能是“编辑框,空白”,而不是“邮箱地址,编辑框”。
常见错误是用 aria-label 替代 label——它会覆盖视觉标签,导致视障用户和明眼人看到/听到的内容不一致;或者用 title 属性,但多数屏幕阅读器根本不读 title。
- 推荐写法:
<label for="email">邮箱地址</label><input id="email" type="email"> - 嵌套写法也合法:
<label>手机号<input type="tel"></label>,但注意避免多重嵌套影响焦点顺序 - 必填字段用
required+aria-required="true"双保险,部分旧版辅助技术只认后者
dialog 元素自带 focus 行为,但不可依赖它解决循环问题
dialog.showModal() 确实会尝试聚焦第一个可聚焦子元素,但它只做一次,不做后续 Tab 循环控制。如果第一个元素是 div tabindex="-1",或内容还没渲染完,焦点就落到 body 上——用户按第一下 Tab 就逃逸了。
更隐蔽的问题是:iOS Safari 和 VoiceOver 对 focus() 极其严格。如果目标元素被 display: none、visibility: hidden 或父级 overflow: hidden 截断,focus() 会静默失败,无报错、无提示。
- 打开前确保至少一个子元素满足可聚焦条件(非
tabindex="-1") - 给
dialog加role="dialog"和aria-modal="true",否则 NVDA/JAWS 不限制焦点范围 - 关闭时别直接存 DOM 节点引用,改用
dataset.triggerId存 ID,再用document.getElementById()查,避免节点被销毁后.focus()失效
焦点样式不能靠 outline: none 一删了之
移除默认 outline 后没补自定义焦点样式,等于主动放弃 WCAG 2.4.7 合规性。键盘用户根本不知道当前聚焦在哪,尤其在纯色背景或卡片式布局里。
:focus-visible 是目前最实用的方案:它只在键盘导航时生效,鼠标点击不触发,兼顾美观与可访问性。但要注意兼容性——旧版 Safari 需要 postcss-focus-visible 或手动 polyfill。
- 禁用
outline必须配替代样式,box-shadow最安全(不占布局空间) - 避免用
border替代,除非同步调整padding或margin抵消尺寸变化 - 所有交互元素(按钮、链接、输入框)都必须有 :focus-visible 样式,包括用
role="button"模拟的按钮
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!











