
本文详解如何在嵌套模态框(modal-1 → modal-2)场景中,通过手动焦点控制与 aria 规范,确保键盘用户始终聚焦于当前活跃模态框,关闭子模态框后精准回归父模态框焦点,满足 wcag 2.1 可访问性要求。
本文详解如何在嵌套模态框(modal-1 → modal-2)场景中,通过手动焦点控制与 aria 规范,确保键盘用户始终聚焦于当前活跃模态框,关闭子模态框后精准回归父模态框焦点,满足 wcag 2.1 可访问性要求。
在构建具备良好无障碍支持(Accessibility)的 Web 应用时,多层模态框(如从页面下拉选择触发 Modal-1,再由 Modal-1 中操作打开 Modal-2)极易引发键盘导航断裂问题:焦点意外逃逸至背景页面、关闭子模态框后焦点丢失或跳转至错误位置。这些问题不仅违反 W3C WAI-ARIA 实践指南,更直接影响视障用户及仅使用键盘操作的用户的可用性。
? 核心原则:焦点围栏(Focus Trap)与焦点回归(Focus Return)
根据 W3C ARIA Authoring Practices Guide (APG) 对话框模式,一个符合标准的模态框必须同时满足两个关键行为:
- 焦点围栏:当模态框打开时,Tab/Shift+Tab 键必须被限制在模态框内部可聚焦元素之间循环,完全禁止焦点进入背景页面;
- 焦点回归:当模态框关闭时,焦点必须精确返回到触发其打开的源元素(例如按钮),而非随意重置或跳至 或 。
⚠️ 注意:原生
✅ 实现方案:分步代码示例
1. 创建焦点围栏(Focus Trap)
为每个模态框(#modal-1, #modal-2)添加 focus-trap 逻辑。以下为轻量级实现(无依赖):
function createFocusTrap(modalElement) {
const focusableElements = modalElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
function handleKeyDown(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
modalElement.addEventListener('keydown', handleKeyDown);
return () => modalElement.removeEventListener('keydown', handleKeyDown);
}
// 启用 Modal-1 的焦点围栏
const modal1 = document.getElementById('modal-1');
const trap1 = createFocusTrap(modal1);
// 启用 Modal-2 的焦点围栏(注意:Modal-1 在 Modal-2 打开时应暂停自身 trap)
const modal2 = document.getElementById('modal-2');
let trap2 = null;
function openModal2() {
// 关闭 Modal-1 的 trap(避免干扰)
trap1();
trap2 = createFocusTrap(modal2);
modal2.showModal(); // 或使用 class 切换 + aria-hidden
}2. 精准焦点回归:关闭 Modal-2 后回到 Modal-1 的触发源
关键点在于记录并恢复上下文。推荐在打开 Modal-2 时,将触发它的按钮(或其他元素)作为 data-opener 存储在 Modal-2 上:
<!-- Modal-1 中的按钮 --> <button type="button" data-open-modal="modal-2" data-opener-id="btn-in-modal1" > 打开二级设置 </button>
// 打开 Modal-2 时记录 opener
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-open-modal="modal-2"]');
if (btn) {
modal2.dataset.openerId = btn.id || `opener-${Date.now()}`;
openModal2();
}
});
// 关闭 Modal-2 后,聚焦回原始触发器
function closeModal2() {
trap2(); // 清理 trap
const openerId = modal2.dataset.openerId;
const opener = document.getElementById(openerId) || modal1.querySelector('button');
if (opener) opener.focus();
modal2.close(); // 或移除显示类
}3. ARIA 层面的语义强化(不可省略)
- 每个模态框需有唯一 id 和 role="dialog";
- 设置 aria-modal="true"(显式声明模态行为);
- 使用 aria-labelledby 指向标题元素,aria-describedby 指向描述内容;
- 背景页面需添加 aria-hidden="true"(Modal-1 打开时)→ Modal-2 打开时,仅 Modal-1 需设 aria-hidden="true",背景页面保持 aria-hidden="false"(因 Modal-1 本身已是“前台”);
<!-- Modal-1 --> <div id="modal-1" role="dialog" aria-modal="true" aria-labelledby="modal1-title"> <h2 id="modal1-title">一级设置</h2> <button id="btn-in-modal1" data-open-modal="modal-2">打开二级设置</button> </div> <!-- Modal-2 --> <div id="modal-2" role="dialog" aria-modal="true" aria-labelledby="modal2-title"> <h2 id="modal2-title">二级设置</h2> <button onclick="closeModal2()">确认</button> </div>
? 注意事项与最佳实践
- ✅ 永远不要依赖 autofocus 属性:它仅在首次渲染时生效,无法应对模态框反复开关场景;
- ✅ 聚焦目标必须是可聚焦元素:确保 focus() 调用对象已渲染、未被 display: none 或 visibility: hidden 隐藏,且 tabindex 合法;
- ✅ 屏幕阅读器兼容性:焦点围栏需配合 aria-hidden 动态切换,否则 NVDA/JAWS 仍可能朗读背景内容;
- ⚠️ 避免“焦点跳跃”体验:关闭 Modal-2 后若 Modal-1 已失焦,应优先聚焦其首个逻辑操作项(如“取消”按钮),而非强制聚焦标题(非交互元素);
- ? 务必全链路测试:使用真实键盘 + NVDA / VoiceOver + Chrome/Firefox/Safari 组合验证 Tab 流、Enter 操作、ESC 关闭、焦点路径是否连贯。
遵循以上结构化实现,即可在复杂嵌套模态场景中,构建真正健壮、合规、用户友好的无障碍交互体验——让每一次 Tab 键按下,都成为可控、可预期、可信赖的操作旅程。










