我最近阅读了 Keith Grant 的 这篇文章,它介绍了新推出的 <dialog>
。 对这个新的 UI 元素感到兴奋,我立即坐下来开始尝试使用它,看看它如何有效地用作模态(它最常见的用途)。 在尝试的过程中,我发现了一个巧妙的 CSS 技巧,可以将焦点捕获在元素内,这是模态的常见无障碍要求,也是一个臭名昭著的难题。
免责声明:本文中的 <dialog>
演示仅在 Chrome 和 Firefox 浏览器中经过测试。 Safari 存在一些奇怪的问题,使用 Tab 键进行正常的键盘导航时,并非所有元素都获得焦点!
什么是焦点捕获?
首先,引用 W3C 文档中关于对话框内按键后应发生的事情的 说明
Tab
- 将焦点移动到对话框内的下一个可获取焦点的元素。
- 如果焦点在对话框内的最后一个可获取焦点的元素上,则将焦点移动到对话框内的第一个可获取焦点的元素。
Shift + Tab
- 将焦点移动到对话框内的上一个可获取焦点的元素。
- 如果焦点在对话框内的第一个可获取焦点的元素上,则将焦点移动到对话框内的最后一个可获取焦点的元素。
总结一下,当在对话框内时,按下 Tab
或 Shift+Tab
应仅在对话框内循环焦点——在对话框内的可获取焦点元素之间循环。
这是有道理的,因为当对话框打开时,用户只在对话框内进行交互,允许焦点逃离对话框实际上是混合了上下文,并可能创建一个用户不知道哪个元素处于焦点状态的情况。
因此,回到模态的概念,我们的期望是,在模态内进行制表符操作应该只将焦点放在模态内的元素上。 模态上下文之外的任何内容都将超出范围,因为制表符只关心模态内部的内容。 这就是我们所说的 焦点捕获。
使用 JavaScript 的实现
如果我们要在 <dialog>
内实现焦点捕获,最常见的方法是在对话框打开时执行以下操作
1. 获取对话框内的所有可获取焦点/可点击元素。
2. 监听 Tab
和 Shift+Tab
按键,并手动将焦点分别移动到下一个或上一个元素。
3. 如果按键发生在第一个可获取焦点的元素上,则将焦点放在链中的最后一个可获取焦点的元素上,反之亦然。
通过这种方式,我们创建了一个关于焦点的循环,当用户按下 Tab
或 Shift+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>
元素内实现一个基本的焦点捕获。
总而言之,这个技巧的工作原理如下。 当焦点不在对话框内(并且对话框处于打开状态)时,我们
- 触发 CSS 过渡
- 在 JavaScript 中检测过渡完成
- 将焦点放在对话框内的第一个元素上
但首先,让我们开始设置。 这是基本的对话框和打开功能
<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 在做什么。
- 我们在对话框上放置了一个我们选择的
background-color
。 这不是必需的,但确保我们在所有浏览器中都拥有相同的背景颜色。 dialog[open]:not(:focus-within)
选择器在对话框打开但没有在对话框上或内部获得焦点时应用。 这是有效的,因为本机元素在打开时会放置一个open
属性。- 在这个规则内,我们通过最小程度的改变了
background-color
。 这是触发 CSS 动画所需的最小改变,同时不会对用户造成任何视觉差异(请记住,这是一个虚拟过渡)。 此外,我们还使用非常短的持续时间设置了transition
属性,因为我们希望它尽快完成并在 JavaScript 中被检测到。
一点 JavaScript 代码
现在,我们只需要检测触发 CSS 过渡的结束,并将焦点放回到模态内的第一个元素上,如下所示
modal.addEventListener('transitionend', (e) => {
modal.querySelector('input').focus();
});
我们在模态上附加了一个 transitionend
监听器,并在回调函数内,我们将焦点放在模态内的第一个输入元素上。 完成!
局限性
这是一个我做的快速实验,用 :focus-within
伪类创建一个可行的焦点捕获概念证明。 与专门的 JavaScript 解决方案相比,它有一些局限性,用于实现此目标。 尽管如此,聊胜于无!
以下列举了此实现缺少的几个方面
- 根据 W3C 指南,焦点应该在可获取焦点的元素上循环。 但我们始终将焦点放在第一个
input
元素上。 这是因为没有编写更多 JavaScript 代码,我们就无法知道焦点是从第一个元素还是最后一个元素丢失的。 - 我们始终将焦点放回到第一个
input
元素上。 但还有许多其他可获取焦点的 HTML 元素可能存在于input
之前,或者模态内根本没有input
元素。 同样,完整的 JavaScript 解决方案会检测并维护所有可获取焦点元素的列表,并将焦点放在正确的元素上。
更好的(JavaScript)焦点捕获实现
- 如前所述,一个工作示例可在 W3C 文档 中找到。
- 这里有 另一个实现,由 Rodney Rehm 实现,它也监听 Tab 键。
- Greg Kraus 有一个 库 来实现这一点。他的实现维护了一个 选择器列表,用于所有有效的可聚焦元素。
- 还有一个 轻量级库 来创建可访问的模态框。
这就是这个实验的全部内容。如果您喜欢这个技巧,您可以在 Twitter 上关注我,在那里我会分享更多我的文章和副项目。
dialog 元素在 Firefox、Safari 或 Edge 中不起作用,因此演示在那里将无法正常运行。这是一个接近的 fork。
我甚至不知道为什么它不能完全工作,也许有人可以把它拿去运行。
嗨 Chris,我已经通过添加 dialog 的 polyfill 来修复了演示。并为 Safari 添加了一个免责声明,即使是普通的 Tab 导航也不起作用!
focusin
事件冒泡,因此您可以像这样操作。有趣!该事件 (focusin) 基本上为我的 CSS
focus-within
方法提供了 JS 替代方案。对于 Safari 标准键盘导航问题,您是否已确保在系统偏好设置->键盘->快捷方式中,将完全键盘访问设置为“所有控件”?
啊,所以问题就在这里。我不知道那个设置。我会更新文章。谢谢 :)
很棒的文章,非常有趣的实验。
我没想到
:focus-within
可以用作焦点陷阱。正如您正确所说,根据 W3C 的所有要求,执行此操作最正确的方法是使用 JS。
因此,我别无选择,只能在我的当前和未来项目中使用 JS 来实现它。
https://goo.gl/g1A958
很棒的文章,看起来对于未来来说是一个不错的解决方案。目前我使用 2 个 data-* 属性 data-first 和 data-last 来定义起点和终点,并且只监听输入并将最后一个移到第一个,反之亦然,以进行反向导航。只需要少量 JS 即可,并可以完全控制顺序。
可以捕获 blur 事件,唯一的问题是等待它完全处理,然后再重置焦点。
这是示例 fork:https://codepen.io/anon/pen/MVVqrq
不幸的是,在使模态框可访问方面,捕获焦点只是解决方案的一部分。模态对话框应该阻止用户与模态框外部的内容进行交互,这也是您文章的重点。屏幕阅读器用户仍然可以使用特定的按键来获取底层内容的访问权限,这些按键可以访问这些内容。根据他们的操作系统/浏览器/屏幕阅读器的品牌和版本,他们可以轻松地激活快捷方式来访问底层表单控件、标题等,这可能会阻止您(否则为什么首先会有模态对话框,对吧?)。解决方案很简单:将
aria-hidden="true"
应用于对话框之外的内容。这实际上是一个非常好的观点,我之前从未考虑过。在过去的一年中,我一直在努力使模态框可访问,但从未遇到过这个问题。非常棒的提示!
我推荐 a11y-dialog 1,它是一个跨浏览器模态对话框解决方案,支持开箱即用的焦点捕获。
这是一个非常酷的实验,不过 Karl 对 a11y 说得对。
对我来说,Inert 属性(带有 polyfill)是我处理模态交互的首选。