← 返回文章

JavaScript / HTML / 无障碍

无障碍模态框实战:用 dialog 正确管理焦点与键盘

模态框看起来只是“盖住页面的一块面板”,实际却同时改变视觉层级、键盘焦点、读屏上下文和页面交互范围。用一个 position: fixed<div> 很容易画出弹窗,却可能让 Tab 键跑到背景按钮、Escape 无法关闭,关闭后焦点也不知道去了哪里。

原生 <dialog> 已经提供模态展示、top layer 和背景不可交互等基础能力。配合正确的标题、初始焦点和关闭后焦点恢复,可以用更少代码实现更可靠的模态体验。

一、从语义化结构开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<button id="delete-trigger" type="button">
删除项目
</button>

<dialog
id="delete-dialog"
aria-labelledby="delete-title"
aria-describedby="delete-description"
>
<h2 id="delete-title">确认删除项目?</h2>
<p id="delete-description">
删除后将无法恢复,请确认是否继续。
</p>

<form method="dialog">
<button value="cancel">取消</button>
<button value="confirm" class="danger">确认删除</button>
</form>
</dialog>

<dialog> 自带 dialog 语义。aria-labelledby 指向可见标题,aria-describedby 指向简短说明。描述如果非常长、包含列表或表格,不一定适合全部作为一个描述读出;WAI-ARIA APG 建议根据内容结构选择更合适的初始焦点,让读屏用户逐段阅读。

form method="dialog" 允许按钮关闭最近的 dialog,并把按钮 value 写入 dialog.returnValue,无需提交网络请求。

二、模态展示要使用 showModal()

1
2
3
4
5
6
const trigger = document.querySelector('#delete-trigger');
const dialog = document.querySelector('#delete-dialog');

trigger.addEventListener('click', () => {
dialog.showModal();
});

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
2
dialog.showModal();
dialog.querySelector('#terms-title')?.focus();

tabindex="-1" 允许脚本聚焦,但不会把标题加入日常 Tab 顺序。

四、Tab、Shift+Tab 和 Escape

WAI-ARIA APG 的模态对话框模式要求 Tab 和 Shift+Tab 保持在对话框的 Tab 序列中,不能移动到背景页面。原生 showModal() 已经提供这类模态行为,通常不需要再手写一套全局 keydown 焦点陷阱。

原生 dialog 通常支持 Escape 触发取消。可以监听 cancel 做业务判断:

1
2
3
4
5
6
dialog.addEventListener('cancel', (event) => {
if (hasUnsavedChanges()) {
event.preventDefault();
showInlineWarning('请先保存或放弃更改');
}
});

只有确实会造成数据丢失时才阻止 Escape。用户通常期待 Escape 能关闭临时界面。

即使支持 Escape,也必须提供可见的关闭或取消按钮,不能让触屏用户和不了解快捷键的人无路可走。

五、关闭后恢复焦点

当对话框关闭,焦点通常应回到打开它的控件,或转移到下一步工作流中更合理的位置。可以显式保存触发元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let opener = null;

document.addEventListener('click', (event) => {
const button = event.target.closest('[data-dialog-target]');
if (!button) return;

const target = document.querySelector(button.dataset.dialogTarget);
if (!(target instanceof HTMLDialogElement)) return;

opener = button;
target.showModal();
});

dialog.addEventListener('close', () => {
if (opener?.isConnected) {
opener.focus();
}
opener = null;
});

删除流程是一个例外:如果触发按钮所属列表项已经被删除,焦点不能回到不存在的节点,应移动到相邻项目、列表标题或新增按钮等逻辑位置。

六、正确处理确认结果

1
2
3
4
5
6
7
8
9
10
dialog.addEventListener('close', async () => {
if (dialog.returnValue !== 'confirm') return;

try {
await deleteProject();
announce('项目已删除');
} catch (error) {
showDeleteError(error);
}
});

不要在点击确认后立刻显示“删除成功”。真正成功要以服务端结果为准。请求期间可以禁用确认按钮并显示进度,但失败时应重新打开或保留明确重试入口,同时恢复可理解的焦点位置。

如果确认操作耗时,直接关闭模态并在页面展示全局进度,往往比让用户困在一个不可关闭的 loading 弹窗里更友好。

七、样式与背景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
dialog {
width: min(32rem, calc(100% - 2rem));
max-height: min(80vh, 42rem);
padding: 1.5rem;
border: 0;
border-radius: 1rem;
overflow: auto;
color: #1f2328;
background: white;
}

dialog::backdrop {
background: rgb(15 23 42 / 55%);
backdrop-filter: blur(2px);
}

@media (prefers-reduced-motion: no-preference) {
dialog[open] {
animation: dialog-in 160ms ease-out;
}
}

限制高度并让 dialog 内部滚动,避免小屏按钮被挤出视口。背景遮罩要有足够对比,但不能依赖视觉遮罩表达“背景不可交互”,真正的模态行为来自 showModal()

点击 backdrop 是否关闭要根据任务风险决定。编辑表单和危险操作不应因为一次误触丢失内容。如果实现点击外部关闭,要检查点击确实落在 dialog 的边界外,而不是内部子元素。

八、不要重复添加 ARIA

原生 <dialog> 已经有对应语义。不要机械添加互相冲突的 role="alertdialog"aria-modal 和多套焦点脚本。alertdialog 只适合需要立即引起注意的特殊警告,不是所有确认框的默认角色。

ARIA 主要补充可访问名称与描述,不能修复错误交互。一个有 role="dialog" 的普通 div 如果背景仍可点击、焦点仍能离开,就仍然不是合格模态框。

九、键盘验收清单

  1. 只用键盘能否打开对话框?
  2. 初始焦点是否落在符合任务风险的位置?
  3. Tab 和 Shift+Tab 是否始终留在对话框内?
  4. Escape 是否按预期关闭,阻止关闭时是否给出原因?
  5. 是否存在可见的取消或关闭按钮?
  6. 关闭后焦点是否回到触发器或合理后续位置?
  7. 标题、描述和错误信息是否能被读屏理解?
  8. 320px 宽度、200% 缩放时内容与按钮是否仍可访问?

模态框的质量不取决于阴影和圆角,而取决于用户是否清楚自己进入了什么上下文、能做什么、如何离开。优先使用原生 dialog,把精力放在初始焦点、关闭策略、结果反馈和真实键盘测试上,代码更少,体验也更可靠。

参考资料