无障碍基建入门:弹窗焦点陷阱与ARIA状态实战

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-07 00:04 ·2 浏览 ·0 回复

弹窗(Dialog)大概是前端组件里最考验“无障碍基本功”的场景之一。很多人以为加了 `role="dialog"` 就万事大吉,结果键盘用户一打开弹窗,焦点要么留在背后的页面里,要么被“吸”进某个角落再也出不来——这就是典型的焦点陷阱。更隐蔽的是,屏幕阅读器可能完全不知道弹窗的存在,读到的还是背后那一大坨内容。今天我们就从这两个最实在的问题入手,聊聊怎么把弹窗的无障碍基础打牢。

焦点陷阱:不是锁死,是“圈养”

所谓“焦点陷阱”,并不是要让 Tab 键失效,而是当弹窗打开时,焦点必须被移入弹窗,并且在弹窗内部循环移动,直到用户主动关闭弹窗。实现这个效果的核心是监听 Tab 键,并把焦点限制在弹窗内可聚焦元素集合里。

function trapFocus(dialog, event) {
  const focusable = dialog.querySelectorAll(
    'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
  );
  if (focusable.length === 0) return;
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  if (event.key === 'Tab') {
    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  }
}

这段代码最容易被忽略的是 `[tabindex]:not([tabindex="-1"])` 这个选择器。如果弹窗里有自定义焦点元素,记得别漏。更简单的做法是给整个弹窗容器设置 `inert` 属性,把背后页面“惰性化”,这样浏览器原生就会阻止焦点跑到外面去。不过 `inert` 的兼容性已经很好,但如果你需要支持老浏览器,上面的手动 trap 仍然是兜底方案。

打开弹窗时,千万别忘了把焦点移到弹窗内部,通常是移到标题或者第一个可交互元素。否则键盘用户按了几下 Tab,焦点还在背后页面里,弹窗却已经盖在屏幕上了,这就是典型的“焦点悬空”。

ARIA 状态:让读屏软件“同步感知”

焦点管理解决的是键盘操作,而屏幕阅读器还需要知道弹窗的“当前状态”。核心是三个属性:`role="dialog"`、`aria-modal="true"`、`aria-labelledby` 指向弹窗标题。其中 `aria-modal="true"` 特别关键,它告诉读屏软件:弹窗之外的内容是“不可见的”,请忽略它们。

但很多实战场景里,弹窗是异步出现的,或者由多个按钮触发同一个弹窗,这时状态管理就容易乱。比如关闭弹窗时,`aria-hidden` 没清理干净,或者弹窗根节点没从 DOM 移除,读屏软件仍然能 Tab 到背后的隐藏按钮。

一个常见的坑是使用 `display:none` 来隐藏弹窗,这本身没问题,但如果你用 CSS 类名切换 opacity/visibility 来做动画,记得在动画结束后设置 `visibility: hidden`,并确保 `inert` 或 `aria-hidden` 同步更新。推荐的做法是:弹窗关闭后,直接移出 DOM,或者保留但不能被聚焦和读取。

function openDialog() {
  dialog.removeAttribute('aria-hidden');
  dialog.setAttribute('aria-modal', 'true');
  dialog.classList.add('is-open');
  focusFirstOrTitle();
}

function closeDialog() {
  dialog.classList.remove('is-open');
  dialog.setAttribute('aria-hidden', 'true');
  triggerButton.focus(); // 焦点归还给触发按钮
}

注意 `aria-hidden="true"` 必须在元素不可见或不可聚焦时才加,否则读屏软件会遇到冲突。还有一点:**别在 `role="dialog"` 的元素上用 `aria-hidden` 隐藏自己,除非你确定它已经完全不参与交互**。

实战:从打开到关闭的完整链路

我们以最常见的“确认删除”弹窗为例,走一遍完整流程。假设场景是后台列表页,用户点击“删除”按钮,弹窗出现,需要用户点“确认”或“取消”。

1. 按钮点击:按钮自身是 `aria-haspopup="dialog"` 表示它会打开对话框。点击后,弹窗渲染。
2. 焦点移入:把焦点放在弹窗标题上,标题元素需要 `tabindex="-1"`,这样能接收焦点但不会进入 Tab 序列。如果没有标题,就放第一个按钮。
3. 循环陷阱:如上面代码所示。
4. 关闭处理:点击取消、点击遮罩、按 Esc 键,三种方式都要实现。其中按 Esc 时,要监听弹窗上的 keydown,而不是 document——否则会误伤其他组件。
5. 焦点归还:关闭后,焦点必须回到触发弹窗的那个删除按钮上,否则键盘用户就“迷路”了。

还有个容易被忽略的细节:如果弹窗内有自动聚焦的输入框,比如“输入理由”,那么在打开动画结束后再聚焦,否则动画期间的布局变化可能让焦点计算错位。

别把“无障碍”做成补丁

很多同学的实践是把这些代码写成一个 `useDialog` 钩子或一个 `Dialog` 组件,然后把所有边界情况都封装进去。这是对的。但如果每次做弹窗都是从头写一遍,那迟早会遗漏。更好的做法是把它沉淀成团队的基础组件,并且写几个自动化测试——比如用 axe 或 jest-dom 断言弹窗打开时背后元素不在可访问树内,Tab 循环的边界情况也能跑通。

另外,ARIA 状态不是写上去就有效,要配合实际交互实时更新。之前我见过一个案例:弹窗已经关闭了,但 `aria-modal` 还在为 true,导致读屏软件把整个页面当成了一个无法退出的模态框。那种体验简直像被困在玻璃盒里。

弹窗的无障碍基建,说到底就是两件事:让焦点别乱跑,让读屏软件状态同步。把这两点做扎实,你的弹窗组件就比市面上大多数同类产品更可靠。下次打开代码前,不妨先拷问自己一句:如果我只用键盘和读屏软件,我能顺利打开、操作、关闭这个弹窗吗?——不能的话,那就先补课吧。

全部回复 0

还没有回复,来抢沙发~