← 中文资源

简体中文版本

在模态对话框里正确困住焦点

打开时移入焦点、Tab 在框内循环、关闭时归还焦点,并处理背景滚动与读屏播报。

阅读英文原文

目标

Tab 从遮罩后面逃出去就是键盘死胡同:用户在给一个看不见的页面打字。合格的模态要做到四件事:打开时焦点移入、Tab 在框内循环、关闭时焦点归还原触发器、打开瞬间向读屏器播报。原生 dialog 元素自带大半,剩下九行 walker 兜底,最后用一份纯键盘剧本逐条验证。

步骤要点

  1. 优先用原生 dialog 加 showModal:自动移入焦点、置顶层渲染、Esc 关闭,只剩关闭归还焦点需要自己在 close 事件里补一行。
  2. 自定义模态则实现 walker:Tab 按下时实时查询可见可聚焦元素,末元素 Tab 回首元素、首元素 Shift+Tab 回末元素、焦点逃逸则立即收回。
  3. 打开关闭配套管理背景:开时存下并冻结 body 滚动,关时恢复滚动并把焦点送回触发器,键盘用户原地续命。
  4. 加 aria 履行播报义务:role=dialog 加 aria-modal=true 加 aria-labelledby 与 aria-describedby,无标题时用 aria-live 区域命令式播报。
  5. 按剧本跑纯键盘走查:Enter 开框焦点落在首个按钮、连按 Tab 五次焦点始终在框内、Shift+Tab 反向回绕、Esc 关框焦点回触发器、NVDA 播报标题与描述。

排错提示

焦点逃到浏览器地址栏说明回绕没在末元素上 preventDefault,walker 的关键就是在首末元素按键时当场拦截;框内动态出现控件后循环失效是把可聚焦列表在打开时缓存了,改为每次按键实时查询即可自动纳入新控件;读屏器仍读背景就把背景容器设 inert,无障碍树直接排除背景,这比任何 z-index 都算数。

适用边界

困焦点只是模态无障碍的三分之一,对比度、焦点可见性与减少动效同样是 WCAG 门槛;原生 dialog 已覆盖大部分行为,自定义实现每绕开一项就要自己补回一项。

代码示例

keyboard walkthrough (the only honest test):
  1. Enter on trigger   -> dialog opens, focus = first button
  2. Tab Tab Tab Tab    -> focus cycles btn1 -> btn2 -> input -> btn1
     (document.activeElement NEVER leaves the dialog)
  3. Shift+Tab from btn1 -> focus = last control (wraps backward)
  4. Esc                -> dialog closes, focus = trigger button
  5. NVDA announces:    -> "Confirm deletion dialog, This action cannot..."

anti-patterns found in the wild:
  - trap list cached at open (dynamic controls break it)
  - focus left on body when closing (keyboard users are lost)
  - overlay div without aria-modal (screen readers read background)