纯前端实现可访问的模态对话框:焦点陷阱、惰性内容与滚动锁定
模态对话框是前端最容易做错的交互组件之一。视觉上它只是一块浮层,但要让键盘和读屏用户完整走完“打开—操作—关闭—回到原处”这条路径,需要同时把语义角色、焦点转移、焦点陷阱和背景惰性化这四件事做对,缺一件就会把人困住或者让人迷路。
一、语义角色与可访问名称
对话框应当使用原生 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 到达、滚动条不抖动、异步错误播报、以及连续开关多次后焦点仍然正确。用读屏走一遍,基本能在几分钟内发现焦点丢失和名称缺失这两类最常见的问题。
八、小结
模态对话框的可访问实现,本质是守住一条完整的焦点路径:打开前记住位置,打开后进入内部并困在其中,关闭后原路返回。语义角色和可访问名称负责让用户知道“这是什么”,惰性化与滚动锁定负责让用户不跑到别处,异步播报负责让用户知道“发生了什么”。这四件事都做到,对话框在各种输入方式和辅助技术下才不会变成一堵墙。
- 点赞
- 收藏
- 关注作者
评论(0)