模态与抽屉的焦点陷阱
约束 Tab 循环在覆盖内并在关闭时恢复焦点。
无障碍进阶
为什么
无焦点陷阱,模态内 Tab 逃到背后页 — 键盘用户交互不可见内容。陷阱焦点使他们留在模态直到显式关闭。
WebAIM Million 2024 报告发现 96.3% 的首页存在可检测的 WCAG 失败。2023 年 ADA 相关诉讼超过 4,500 起,使无障碍成为法律和伦理必需,而非可有可无。
何时使用
每模态、对话框、抽屉、覆页弹出用焦点陷阱。内联折叠跳过 — 它们是页流一部分。
如何做
1. 开时 — 存 document.activeElement 作恢复目标,移焦点到模态首个可聚焦
2. 陷阱 — Tab 时若在末可聚焦且按 Tab,循环到首;首处 Shift+Tab 循环到末
3. 恢复 — 关时对存的目标调 .focus()(开模态的按钮)
4. 库 — focus-trap-react(3KB)处理边界;或 Radix Dialog / Headless UI Dialog
5. 初始焦点 — 聚焦模态标题或首输入,非容器(容器需 tabindex=-1)
6. 守卫 — 忽模态外点击(或外点击关),但绝不让焦点离
7. 嵌套 — 第二陷阱堆叠;关内恢复到外,非页
示例
import { FocusTrap } from "focus-trap-react";
function Modal({ open, onClose, title, children }: Props) {
const restoreRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
restoreRef.current = document.activeElement as HTMLElement;
return () => restoreRef.current?.focus(); // 关时恢复
}, [open]);
if (!open) return null;
return (
<FocusTrap>
<div role="dialog" aria-modal="true" aria-labelledby="t">
<h2 id="t" tabIndex={-1}>{title}</h2>
{children}
<button onClick={onClose}>关闭</button>
</div>
</FocusTrap>
);
}
// 或手卷:查可聚焦,在边界处理 Tab/Shift+Tab
const focusables = (el: HTMLElement) =>
el.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
常见问题
- ⚠无焦点恢复 — 模态关,焦点落 body,屏幕阅读器用户迷
- ⚠陷阱漏动态加可聚焦 — 表单字段渲染后 Tab 逃
- ⚠初始焦点在容器 — 屏幕阅读器播报 div 非对话框目的
最佳实践
- ✓关时始终恢复焦点 — 回到开模态的按钮,非 document.body
- ✓用维护库(focus-trap-react、Radix Dialog)— 处理动态可聚焦 + 嵌套陷阱
- ✓开时聚焦标题或首输入 — 给屏幕阅读器用户即时上下文