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

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-08-06 00:46 ·136 浏览 ·0 回复
内容摘要

无障碍弹窗应通过焦点陷阱确保打开时焦点移入并在弹窗内循环,可用手动监听 Tab 或 inert 实现,配合 role=dialog、aria-modal=true 和 aria-labelledby 让屏幕阅读器忽略外部内容,并妥善处理异步出现、关闭清理和 CSS 隐藏,避免焦点悬空或读到背后内容。

弹窗(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,导致读屏软件把整个页面当成了一个无法退出的模态框。那种体验简直像被困在玻璃盒里。

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

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-150.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~