纯前端实现可访问的模态对话框:焦点陷阱、惰性内容与滚动锁定

举报
yd_213843956 发表于 2026/11/09 07:42:15 2026/11/09
【摘要】 模态对话框的可访问性依赖四件事同时正确:语义角色与名称、打开与关闭时的焦点转移、焦点陷阱与 Esc 退出、以及背景内容的惰性化处理。

模态对话框是前端最容易做错的交互组件之一。视觉上它只是一块浮层,但要让键盘和读屏用户完整走完“打开—操作—关闭—回到原处”这条路径,需要同时把语义角色、焦点转移、焦点陷阱和背景惰性化这四件事做对,缺一件就会把人困住或者让人迷路。

一、语义角色与可访问名称

对话框应当使用原生 dialog 元素,它自带 showModal() 方法,会自动把背景内容变为惰性,并把焦点收进对话框。如果因为兼容性或样式原因必须自己实现,那么容器要用 role="dialog" 并配合 aria-modal="true"。

对话框必须有可访问名称。最简单的方式是让标题元素带 id,然后容器上写 aria-labelledby 指向它;如果标题是图形文字而非常规文本,就用 aria-label 直接给名字。名称的作用是在读屏里播报“某某对话框”,没有名字时读屏只会念“对话框”,用户不知道眼前是什么。

<div role="dialog" aria-modal="true" aria-labelledby="dlg-title">
  <h2 id="dlg-title">删除此项目</h2>
  <p>删除后无法恢复。</p>
  <button>取消</button>
  <button>确认删除</button>
</div>

如果对话框里还有一段更长的说明需要读屏先读出来,用 aria-describedby 指向说明段落,这样打开时会先播报名字再播报说明,用户不用自己去探索。

二、打开与关闭时的焦点转移

打开对话框时,焦点必须移动到对话框内部。移动到哪里要看场景:如果第一个可交互元素就是用户最可能操作的对象,可以直接聚焦它;如果对话框内容较长或开头是纯说明文字,就聚焦对话框容器本身,让读屏从头读起,用户按 Tab 再进入控件。

关闭时,焦点必须回到触发按钮。这一点经常被忽略,结果是用户关闭对话框后焦点落在 body 上,接着按 Tab 就跳到了页面最开头,需要重新找路。实现上要在打开时记录 document.activeElement,关闭后调用它的 focus(),并在元素已被移除时做一个兜底。

let lastFocused = null;
function openDialog(dialog, trigger) {
  lastFocused = trigger || document.activeElement;
  dialog.hidden = false;
  dialog.focus();
}
function closeDialog(dialog) {
  dialog.hidden = true;
  if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
  else document.body.focus();
}

三、焦点陷阱的正确边界

焦点陷阱不能简单地拦截 Tab 并强制回到第一个元素,那样会漏掉对话框里的可滚动区域和 iframe。正确做法是动态查询当前可聚焦的元素列表,并且每次按键都重新查询,因为对话框内容可能随时变化。

可聚焦的判定要排除 disabled、hidden、aria-hidden 和 tabindex="-1" 的元素,也要排除被 CSS 隐藏的祖先下的元素。查询时用 element.offsetParent 或者 getClientRects().length 来确认可见性。

function trap(event, dialog) {
  if (event.key !== 'Tab') return;
  const nodes = dialog.querySelectorAll("a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])");
  const list = [...nodes].filter((node) => node.getClientRects().length > 0);
  if (!list.length) return;
  const first = list[0];
  const last = list[list.length - 1];
  if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  } else if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  }
}

还要处理 Esc 关闭。Esc 应当只在用户没有正在输入组合键的时候触发,输入法组字状态下的 Esc 是取消组字,不应关闭对话框。用 event.isComposing 判断即可。

四、背景惰性化与滚动锁定

aria-modal="true" 只是告诉读屏“这是模态”,它并不会真的阻止读屏用户用快捷导航跑到背景内容里。可靠的做法是给模态之外的兄弟节点加上 inert 属性,或者加 aria-hidden="true",从可访问树里整体摘除。

注意不要给对话框的祖先链上的节点加 aria-hidden,否则对话框自己也被隐藏了。正确做法是找到对话框所在容器的兄弟节点逐个设置。inert 比 aria-hidden 更好,因为它同时阻止了鼠标点击和焦点进入。

滚动锁定要区分两种情况。视口锁定时给 body 加 overflow: hidden,但要补偿滚动条宽度,否则页面会横向抖动。更稳妥的是记录当前 scrollTop,改为 fixed 定位后再还原,适合移动端。

function lockScroll() {
  const gap = window.innerWidth - document.documentElement.clientWidth;
  document.body.style.overflow = 'hidden';
  document.body.style.paddingRight = gap + 'px';
}
function unlockScroll() {
  document.body.style.overflow = '';
  document.body.style.paddingRight = '';
}

对话框内部的滚动不应触发背景滚动穿透,用 overscroll-behavior: contain 可以省掉不少手动拦截的逻辑。

五、异步内容与状态播报

对话框里的表单提交、加载过程需要播报结果。在对话框内部放一个 aria-live="polite" 的状态区,提交成功后写入“已保存”,加载失败写入错误原因。错误信息要同时通过 aria-describedby 绑到对应输入框,让用户在该字段上就能听到错误。

如果对话框在打开后才异步加载内容,打开时应先聚焦容器并播报“正在加载”,加载完成后不要粗暴地把焦点跳到新出现的第一个输入框,那样会打断用户的阅读顺序;改为播报“内容已加载”,让用户自行按 Tab 前进。

六、多层对话框

对话框里再开对话框时,要为每一层维护独立的焦点记录与陷阱栈。Esc 只关闭最上面一层,关闭后焦点回到上一层对话框内的触发元素。用一个栈结构保存每层的 lastFocused 和容器引用,是最不容易出错的做法。

层数不宜过多,超过两层时用户很容易迷失位置。如果确实需要深层流程,考虑改成单对话框内的分步视图,用面包屑或步骤条表达进度。

七、测试重点

覆盖这些场景:纯键盘打开与关闭、关闭后焦点回到触发按钮、Tab 在首尾之间的环绕、Shift+Tab 反向环绕、Esc 关闭、输入法组字时 Esc 不关闭、背景内容不可 Tab 到达、滚动条不抖动、异步错误播报、以及连续开关多次后焦点仍然正确。用读屏走一遍,基本能在几分钟内发现焦点丢失和名称缺失这两类最常见的问题。

八、小结

模态对话框的可访问实现,本质是守住一条完整的焦点路径:打开前记住位置,打开后进入内部并困在其中,关闭后原路返回。语义角色和可访问名称负责让用户知道“这是什么”,惰性化与滚动锁定负责让用户不跑到别处,异步播报负责让用户知道“发生了什么”。这四件事都做到,对话框在各种输入方式和辅助技术下才不会变成一堵墙。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。