搞不懂模态框焦点管理?,原生 已经不用再操这个心了

2,858字
12–18 分钟
in

以前做模态框,总得写一堆代码把焦点锁在弹窗里,生怕用户一个 Tab 键就溜到外面去了。结果最近才发现,用原生的 <dialog> 元素配合 showModal 方法,浏览器自己就把这事儿办了。不仅不用再写那些复杂的焦点陷阱代码,而且用户能正常操作浏览器地址栏和菜单,反而成了更自然的“逃生舱”。

目录

为什么老方法过时了

以前网上的那些无障碍教程,基本都会强调模态框打开时必须把焦点锁死在弹窗内部。这招在完全用 JavaScript 模拟的模态框里确实好使,因为要是没这层限制,按一下 Tab 键,焦点就会跑到页面背后那些不该被操作的按钮上,整个体验就乱套了。

不过现在的情况早就不一样了。随着 <dialog> 这个原生标签和 inert 属性的普及,浏览器底层已经能处理这种“只让弹窗可交互”的状态。很多早期关于焦点管理的规范,比如 WCAG 那份说明文档里提到的做法,其实都是针对自己用脚本造轮子的模态框写的。那个时候 <dialog> 还没这么好使,不把焦点锁住,就只能把所有背景元素挨个设置 tabindex="-1",想想就头大。

用 showModal 搞定模态框

创建基础模态框结构

先用最简单的 HTML 搭一个弹窗架子,这里的关键是一定要加上 id 属性,方便后续用脚本控制它。

<dialog id="demoModal">
  <p>这里随便写点弹窗里的内容</p>
  <button id="closeBtn">关掉它</button>
</dialog>

<button id="openBtn">点我打开弹窗</button>

注意:刚写好的 <dialog> 在页面上是看不见的,因为它的默认样式是隐藏状态。千万别手贱在 CSS 里给它加个 display: block 啥的,那样会破坏它原有的行为逻辑。

使用 showModal 打开弹窗

在 JavaScript 里获取到这两个按钮,给打开按钮绑定点击事件,然后调用 <dialog> 元素的 showModal 方法。

const modal = document.getElementById('demoModal');
const openBtn = document.getElementById('openBtn');
const closeBtn = document.getElementById('closeBtn');

openBtn.addEventListener('click', () => {
  modal.showModal(); // 这里用 showModal,不是 show()
});

当这段代码执行完,浏览器会干几件重要的事:把弹窗显示出来,在背景上盖一层半透明遮罩,最关键的是,它自动把除了弹窗内部以外的所有东西都标记为“不可交互”。这时候哪怕狂按 Tab 键,焦点也只会在弹窗里的按钮和链接之间转圈。

注意:一定要用 showModal() 而不是 show()。后者只是单纯显示元素,没有遮罩层,也不会限制背景交互。要是用错了,就回到老路子上了,还得自己处理焦点问题。

关闭弹窗的正确姿势

弹窗得能关上,不然用户得摔键盘。关闭的方法很简单,调用 close 方法就行。

closeBtn.addEventListener('click', () => {
  modal.close(); // 优雅地关掉弹窗
});

遇到特殊场景的备用方案

虽然原生 <dialog> 已经够用了,但有些项目可能因为浏览器兼容性要求,或者老代码里已经有一套自定义模态框,没法直接换。这时候也有两种玩法可以参考。

方案一:给自定义模态框加焦点锁

如果非要用 div 模拟模态框,那就得把焦点管理的活干细了。假设有一个 div 当弹窗,打开时得用 JavaScript 收集所有能获取焦点的元素,然后在它们之间循环。

function trapFocus(element) {
  const focusableItems = element.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const firstItem = focusableItems[0];
  const lastItem = focusableItems[focusableItems.length - 1];

  element.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      // 按 Shift+Tab 时,如果焦点在第一个元素上,就跳到最后一个
      if (document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      }
    } else {
      // 按 Tab 时,如果焦点在最后一个元素上,就跳到第一个
      if (document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    }
  });
}

调用的时候在弹窗显示后执行,把弹窗的根节点传进去。这招有点像在一条单行道上掉头,让焦点永远在圈里打转。

方案二:给原生弹窗加个保险

虽然原则上不用管焦点,但遇到某些奇葩的第三方插件或者框架劫持了 Tab 键行为,也可以给 <dialog> 加一道防火墙。监听弹窗的 keydown 事件,只保底处理一下最极端的情况。

modal.addEventListener('keydown', (e) => {
  if (e.key === 'Tab') {
    const focusable = modal.querySelectorAll('button, [href], input');
    if (focusable.length === 1 && document.activeElement === focusable[0]) {
      // 只有单个可聚焦元素时,按 Tab 让它留在原地,不跳出去
      e.preventDefault();
    }
  }
});

这种玩法属于“多管闲事”,一般情况下用不着,但要是产品经理非说用户按 Tab 的时候浏览器地址栏闪一下影响体验,那就得这么干了。

那些年踩过的坑

写原生模态框的时候有个特别容易翻车的地方,就是打开和关闭时的焦点恢复。用 showModal 打开时,浏览器会自动记住打开前那个按钮的位置,关掉之后焦点会乖乖回到那个按钮上。但要是自己用 CSS 模拟弹窗,关掉之后焦点往往还在弹窗里面,得手动调用 focus() 回到触发元素上。

还有一个坑是弹窗里的表单提交。如果 <dialog> 里包了一个 <form method="dialog">,点提交按钮的时候不仅会关掉弹窗,还会把表单数据带出去。这个特性有时候挺好用,但得记住不要在 form 上乱加 action 属性,否则页面一刷新,弹窗是关了,但用户一脸懵。

另外,拿捏不准的时候可以打开浏览器的开发者工具,看 Elements 面板。用 showModal 打开的弹窗,在 DOM 树里会多一个 ::backdrop 伪元素,这就是那个遮罩层。要是没看到它,十有八九是代码里调用错了方法。