使用 CSS 在元素内捕获焦点

Avatar of Kushagra Gour
Kushagra Gour

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

我最近阅读了 Keith Grant 的 这篇文章,它介绍了新推出的 <dialog>。 对这个新的 UI 元素感到兴奋,我立即坐下来开始尝试使用它,看看它如何有效地用作模态(它最常见的用途)。 在尝试的过程中,我发现了一个巧妙的 CSS 技巧,可以将焦点捕获在元素内,这是模态的常见无障碍要求,也是一个臭名昭著的难题。

免责声明:本文中的 <dialog> 演示仅在 Chrome 和 Firefox 浏览器中经过测试。 Safari 存在一些奇怪的问题,使用 Tab 键进行正常的键盘导航时,并非所有元素都获得焦点!

什么是焦点捕获?

首先,引用 W3C 文档中关于对话框内按键后应发生的事情的 说明

Tab

  • 将焦点移动到对话框内的下一个可获取焦点的元素。
  • 如果焦点在对话框内的最后一个可获取焦点的元素上,则将焦点移动到对话框内的第一个可获取焦点的元素。

Shift + Tab

  • 将焦点移动到对话框内的上一个可获取焦点的元素。
  • 如果焦点在对话框内的第一个可获取焦点的元素上,则将焦点移动到对话框内的最后一个可获取焦点的元素。

总结一下,当在对话框内时,按下 TabShift+Tab 应仅在对话框内循环焦点——在对话框内的可获取焦点元素之间循环。

这是有道理的,因为当对话框打开时,用户只在对话框内进行交互,允许焦点逃离对话框实际上是混合了上下文,并可能创建一个用户不知道哪个元素处于焦点状态的情况。

因此,回到模态的概念,我们的期望是,在模态内进行制表符操作应该只将焦点放在模态内的元素上。 模态上下文之外的任何内容都将超出范围,因为制表符只关心模态内部的内容。 这就是我们所说的 焦点捕获

使用 JavaScript 的实现

如果我们要在 <dialog> 内实现焦点捕获,最常见的方法是在对话框打开时执行以下操作

1. 获取对话框内的所有可获取焦点/可点击元素。
2. 监听 TabShift+Tab 按键,并手动将焦点分别移动到下一个或上一个元素。
3. 如果按键发生在第一个可获取焦点的元素上,则将焦点放在链中的最后一个可获取焦点的元素上,反之亦然。

通过这种方式,我们创建了一个关于焦点的循环,当用户按下 TabShift+Tab 时。 请参阅 这个 W3C 代码片段 作为如何使用 JavaScript 实现此功能的示例。 您会发现它 相当多 的 JavaScript 代码。

进入 :focus-within

回到我对新 <dialog> 元素的实验。 当思考焦点捕获时,一个 CSS 伪类(在浏览器中也是很新的)立即浮现在我的脑海中::focus-within

如果您以前没有听说过它,它代表一个获得了焦点或包含获得了焦点的元素的元素。 因此,例如,您有一个 <div>,其中包含一个 input 元素。 如果您想在包含的 input 获得焦点时设置 <div> 的样式,您可以这样做

div:focus-within {
  border: 2px solid red;
}

焦点捕获的 CSS 技巧

让我们利用 :focus-within 和 CSS 过渡来在 <dialog> 元素内实现一个基本的焦点捕获。

总而言之,这个技巧的工作原理如下。 当焦点不在对话框内(并且对话框处于打开状态)时,我们

  1. 触发 CSS 过渡
  2. 在 JavaScript 中检测过渡完成
  3. 将焦点放在对话框内的第一个元素上

但首先,让我们开始设置。 这是基本的对话框和打开功能

<button id="button">Open dialog</button>
<dialog id="modal">
  <form action="">
    <label>
      <input type="text" /> Username
    </label>
    <label>
      <input type="password" /> Password
    </label>
    <input type="submit" value="Submit" />
  </form>
</dialog>
button.onclick = () => {
  modal.showModal();
}

好了。 点击按钮应该会打开我们的对话框。 仅此代码就足以使用新的 <dialog> 创建一个基本的工作模态。

注意:如上面对话框的示例演示所示,您会注意到一些额外的 polyfill 代码,以便在不支持 <dialog> 的浏览器中使其正常工作。

如果您在上面的示例中打开了对话框并多次按下 Tab 键,您可能已经注意到问题所在:焦点从对话框中的元素开始,但在经过对话框中的最后一个元素后就离开了。

这是我们技巧的核心。 我们需要以某种方式将使用 :focus-within 检测到的丢失焦点发送到 JavaScript,以便我们可以将焦点发送回对话框。 这就是 CSS 过渡发挥作用的地方。 CSS 过渡是通过 CSS 发生的,但也会在 JavaScript 中发出事件。 在我们的例子中,我们可以对任何属性触发过渡,该属性具有微不足道的(因为在我们的例子中无关紧要)视觉差异,并在 JavaScript 中监听过渡完成。

请注意,我们需要在对话框打开但没有在对话框内获得焦点时触发此过渡。

dialog {
  background-color: rgb(255, 255, 255);
}
dialog[open]:not(:focus-within) {
  background-color: rgb(255, 255, 254);
  transition: background-color 0.01s;
}

让我们看看这个 CSS 在做什么。

  1. 我们在对话框上放置了一个我们选择的 background-color。 这不是必需的,但确保我们在所有浏览器中都拥有相同的背景颜色。
  2. dialog[open]:not(:focus-within) 选择器在对话框打开但没有在对话框上或内部获得焦点时应用。 这是有效的,因为本机元素在打开时会放置一个 open 属性。
  3. 在这个规则内,我们通过最小程度的改变了 background-color。 这是触发 CSS 动画所需的最小改变,同时不会对用户造成任何视觉差异(请记住,这是一个虚拟过渡)。 此外,我们还使用非常短的持续时间设置了 transition 属性,因为我们希望它尽快完成并在 JavaScript 中被检测到。

一点 JavaScript 代码

现在,我们只需要检测触发 CSS 过渡的结束,并将焦点放回到模态内的第一个元素上,如下所示

modal.addEventListener('transitionend', (e) => {
  modal.querySelector('input').focus();
});

我们在模态上附加了一个 transitionend 监听器,并在回调函数内,我们将焦点放在模态内的第一个输入元素上。 完成!

局限性

这是一个我做的快速实验,用 :focus-within 伪类创建一个可行的焦点捕获概念证明。 与专门的 JavaScript 解决方案相比,它有一些局限性,用于实现此目标。 尽管如此,聊胜于无!

以下列举了此实现缺少的几个方面

  1. 根据 W3C 指南,焦点应该在可获取焦点的元素上循环。 但我们始终将焦点放在第一个 input 元素上。 这是因为没有编写更多 JavaScript 代码,我们就无法知道焦点是从第一个元素还是最后一个元素丢失的。
  2. 我们始终将焦点放回到第一个 input 元素上。 但还有许多其他可获取焦点的 HTML 元素可能存在于 input 之前,或者模态内根本没有 input 元素。 同样,完整的 JavaScript 解决方案会检测并维护所有可获取焦点元素的列表,并将焦点放在正确的元素上。

更好的(JavaScript)焦点捕获实现

  1. 如前所述,一个工作示例可在 W3C 文档 中找到。
  2. 这里有 另一个实现,由 Rodney Rehm 实现,它也监听 Tab 键。
  3. Greg Kraus 有一个 来实现这一点。他的实现维护了一个 选择器列表,用于所有有效的可聚焦元素。
  4. 还有一个 轻量级库 来创建可访问的模态框。

这就是这个实验的全部内容。如果您喜欢这个技巧,您可以在 Twitter 上关注我,在那里我会分享更多我的文章和副项目。