JavaScript / HTML / 无障碍
无障碍模态框实战:用 dialog 正确管理焦点与键盘
模态框看起来只是“盖住页面的一块面板”,实际却同时改变视觉层级、键盘焦点、读屏上下文和页面交互范围。用一个 position: fixed 的 <div> 很容易画出弹窗,却可能让 Tab 键跑到背景按钮、Escape 无法关闭,关闭后焦点也不知道去了哪里。
原生 <dialog> 已经提供模态展示、top layer 和背景不可交互等基础能力。配合正确的标题、初始焦点和关闭后焦点恢复,可以用更少代码实现更可靠的模态体验。
一、从语义化结构开始
1 | <button id="delete-trigger" type="button"> |
<dialog> 自带 dialog 语义。aria-labelledby 指向可见标题,aria-describedby 指向简短说明。描述如果非常长、包含列表或表格,不一定适合全部作为一个描述读出;WAI-ARIA APG 建议根据内容结构选择更合适的初始焦点,让读屏用户逐段阅读。
form method="dialog" 允许按钮关闭最近的 dialog,并把按钮 value 写入 dialog.returnValue,无需提交网络请求。
二、模态展示要使用 showModal()
1 | const trigger = document.querySelector('#delete-trigger'); |
showModal() 会把对话框放进 top layer,并使对话框之外的页面内容对交互呈现 inert。直接添加 open 属性或调用 show() 只是非模态展示,背景仍可交互,不能替代真正的模态行为。
不要在 CSS 中用极大的 z-index 模拟 top layer。它无法自动处理背景交互和键盘范围,还可能被其他层叠上下文压住。
三、设置合理的初始焦点
打开模态框后,焦点应该进入对话框。浏览器会执行原生焦点算法,但业务仍需根据内容确认最合适的位置。
危险确认框通常把初始焦点放在“取消”按钮,避免用户按 Enter 意外执行破坏操作:
1 | <button value="cancel" autofocus>取消</button> |
如果对话框主要是一个输入任务,可以聚焦第一个输入框:
1 | <input id="project-name" name="projectName" autofocus /> |
如果内容很长,直接把焦点放到末尾按钮会让开头标题滚出视口。可以给标题或首段设置 tabindex="-1",打开后用脚本聚焦,使用户从内容开头开始阅读:
1 | <h2 id="terms-title" tabindex="-1">服务条款更新</h2> |
1 | dialog.showModal(); |
tabindex="-1" 允许脚本聚焦,但不会把标题加入日常 Tab 顺序。
四、Tab、Shift+Tab 和 Escape
WAI-ARIA APG 的模态对话框模式要求 Tab 和 Shift+Tab 保持在对话框的 Tab 序列中,不能移动到背景页面。原生 showModal() 已经提供这类模态行为,通常不需要再手写一套全局 keydown 焦点陷阱。
原生 dialog 通常支持 Escape 触发取消。可以监听 cancel 做业务判断:
1 | dialog.addEventListener('cancel', (event) => { |
只有确实会造成数据丢失时才阻止 Escape。用户通常期待 Escape 能关闭临时界面。
即使支持 Escape,也必须提供可见的关闭或取消按钮,不能让触屏用户和不了解快捷键的人无路可走。
五、关闭后恢复焦点
当对话框关闭,焦点通常应回到打开它的控件,或转移到下一步工作流中更合理的位置。可以显式保存触发元素:
1 | let opener = null; |
删除流程是一个例外:如果触发按钮所属列表项已经被删除,焦点不能回到不存在的节点,应移动到相邻项目、列表标题或新增按钮等逻辑位置。
六、正确处理确认结果
1 | dialog.addEventListener('close', async () => { |
不要在点击确认后立刻显示“删除成功”。真正成功要以服务端结果为准。请求期间可以禁用确认按钮并显示进度,但失败时应重新打开或保留明确重试入口,同时恢复可理解的焦点位置。
如果确认操作耗时,直接关闭模态并在页面展示全局进度,往往比让用户困在一个不可关闭的 loading 弹窗里更友好。
七、样式与背景
1 | dialog { |
限制高度并让 dialog 内部滚动,避免小屏按钮被挤出视口。背景遮罩要有足够对比,但不能依赖视觉遮罩表达“背景不可交互”,真正的模态行为来自 showModal()。
点击 backdrop 是否关闭要根据任务风险决定。编辑表单和危险操作不应因为一次误触丢失内容。如果实现点击外部关闭,要检查点击确实落在 dialog 的边界外,而不是内部子元素。
八、不要重复添加 ARIA
原生 <dialog> 已经有对应语义。不要机械添加互相冲突的 role="alertdialog"、aria-modal 和多套焦点脚本。alertdialog 只适合需要立即引起注意的特殊警告,不是所有确认框的默认角色。
ARIA 主要补充可访问名称与描述,不能修复错误交互。一个有 role="dialog" 的普通 div 如果背景仍可点击、焦点仍能离开,就仍然不是合格模态框。
九、键盘验收清单
- 只用键盘能否打开对话框?
- 初始焦点是否落在符合任务风险的位置?
- Tab 和 Shift+Tab 是否始终留在对话框内?
- Escape 是否按预期关闭,阻止关闭时是否给出原因?
- 是否存在可见的取消或关闭按钮?
- 关闭后焦点是否回到触发器或合理后续位置?
- 标题、描述和错误信息是否能被读屏理解?
- 320px 宽度、200% 缩放时内容与按钮是否仍可访问?
模态框的质量不取决于阴影和圆角,而取决于用户是否清楚自己进入了什么上下文、能做什么、如何离开。优先使用原生 dialog,把精力放在初始焦点、关闭策略、结果反馈和真实键盘测试上,代码更少,体验也更可靠。
参考资料
- MDN:dialog element — https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog
- WAI-ARIA APG:Dialog Modal Pattern — https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/