从头开始创建一个可访问的对话框
已发表: 2022-03-10首先,不要在家里这样做。 不要编写自己的对话框或库来这样做。 那里已经有很多已经过测试、审核、使用和重用了,你应该更喜欢这些而不是你自己的。 a11y-dialog 就是其中之一,但还有更多(在本文末尾列出)。
借这个帖子,提醒大家在使用对话框时要小心。 用它们来解决所有设计问题,尤其是在移动设备上,是很有诱惑力的,但通常还有其他方法可以克服设计问题。 我们倾向于很快陷入使用对话框,不是因为它们一定是正确的选择,而是因为它们很容易。 他们通过交换上下文切换来解决屏幕空间问题,这并不总是正确的权衡。 关键是:在使用对话框之前考虑它是否是正确的设计模式。
在这篇文章中,我们将编写一个小型 JavaScript 库,用于从一开始就创建可访问的对话框(本质上是重新创建 a11y-dialog)。 目标是了解其中的内容。 我们不会过多地处理样式,只处理 JavaScript 部分。 为了简单起见,我们将使用现代 JavaScript(例如类和箭头函数),但请记住,此代码可能不适用于旧版浏览器。
- 定义 API
- 实例化对话框
- 显示和隐藏
- 用覆盖关闭
- 以逃跑结束
- 捕捉焦点
- 保持专注
- 恢复焦点
- 提供可访问的名称
- 处理自定义事件
- 打扫干净
- 把它们放在一起
- 包起来
定义 API
首先,我们要定义我们将如何使用我们的对话脚本。 一开始我们会尽量保持简单。 我们将对话框的根 HTML 元素赋予它,我们得到的实例有一个.show(..)
和一个 .hide( .hide(..)
方法。
class Dialog { constructor(element) {} show() {} hide() {} }
实例化对话框
假设我们有以下 HTML:
<div>This will be a dialog.</div>
我们像这样实例化我们的对话框:
const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)
在实例化它时,我们需要在后台做一些事情:
- 隐藏它,使其默认隐藏(
hidden
)。 - 将其标记为辅助技术的对话框(
role="dialog"
)。 - 打开时使页面的其余部分保持惰性(
aria-modal="true"
)。
constructor (element) { // Store a reference to the HTML element on the instance so it can be used // across methods. this.element = element this.element.setAttribute('hidden', true) this.element.setAttribute('role', 'dialog') this.element.setAttribute('aria-modal', true) }
请注意,我们可以在初始 HTML 中添加这 3 个属性,而不必使用 JavaScript 添加它们,但这样一来它就看不见了,心不在焉。 无论我们是否考虑过添加所有属性,我们的脚本都可以确保一切正常。
显示和隐藏
我们有两种方法:一种显示对话框,另一种隐藏它。 除了切换根元素上的hidden
属性之外,这些方法(目前)不会做太多事情。 我们还将在实例上维护一个布尔值,以便能够快速评估对话框是否显示。 这将在以后派上用场。
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
为了避免对话框在 JavaScript 启动之前可见并通过添加属性将其隐藏,从一开始就直接在 HTML 中将hidden
添加到对话框中可能会很有趣。
<div hidden>This will be a dialog.</div>
用覆盖关闭
在对话框之外单击应将其关闭。 有几种方法可以做到这一点。 一种方法是监听页面上的所有点击事件并过滤掉对话框中发生的事件,但这样做相对复杂。
另一种方法是监听叠加层上的点击事件(有时称为“背景”)。 叠加层本身可以像带有一些样式的<div>
一样简单。
所以在打开对话框时,我们需要在叠加层上绑定点击事件。 我们可以给它一个 ID 或某个类以便能够查询它,或者我们可以给它一个数据属性。 我倾向于将这些用于行为挂钩。 让我们相应地修改我们的 HTML:
<div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>
现在,我们可以在对话框中使用data-dialog-hide
属性查询元素,并为它们提供一个隐藏对话框的点击侦听器。
constructor (element) { // … rest of the code // Bind our methods so they can be used in event listeners without losing the // reference to the dialog instance this._show = this.show.bind(this) this._hide = this.hide.bind(this) const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) }
拥有像这样通用的东西的好处是我们也可以对对话框的关闭按钮使用相同的东西。
<div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
以 Escape 结束
不仅在单击对话框外部时应该隐藏对话框,而且在按下Esc时也应该隐藏它。 打开对话框时,我们可以将键盘监听器绑定到文档,并在关闭时将其移除。 这样,它只在对话框打开时而不是一直监听按键。
show() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.addEventListener('keydown', this._handleKeyDown) } hide() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.removeEventListener('keydown', this._handleKeyDown) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() }
诱捕焦点
现在这是好东西。 在对话中捕获焦点是整个事情的本质,并且必须是最复杂的部分(尽管可能没有您想象的那么复杂)。
这个想法很简单:当对话框打开时,我们监听Tab按下。 如果在对话框的最后一个可聚焦元素上按Tab ,我们会以编程方式将焦点移至第一个。 如果在对话框的第一个可聚焦元素上按Shift + Tab ,我们会将其移动到最后一个。
该函数可能如下所示:
function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } }
接下来我们需要弄清楚的是如何获取对话框的所有可聚焦元素( getFocusableChildren
)。 我们需要查询所有理论上可以聚焦的元素,然后我们需要确保它们有效。
第一部分可以使用可聚焦选择器完成。 这是我写的一个很小的包,它提供了这个选择器数组:
module.exports = [ 'a[href]:not([tabindex^="-"])', 'area[href]:not([tabindex^="-"])', 'input:not([type="hidden"]):not([type="radio"]):not([disabled]):not([tabindex^="-"])', 'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked', 'select:not([disabled]):not([tabindex^="-"])', 'textarea:not([disabled]):not([tabindex^="-"])', 'button:not([disabled]):not([tabindex^="-"])', 'iframe:not([tabindex^="-"])', 'audio[controls]:not([tabindex^="-"])', 'video[controls]:not([tabindex^="-"])', '[contenteditable]:not([tabindex^="-"])', '[tabindex]:not([tabindex^="-"])', ]
这足以让你达到 99%。 我们可以使用这些选择器来查找所有可聚焦的元素,然后我们可以检查它们中的每一个以确保它在屏幕上实际上是可见的(而不是隐藏或其他什么)。
import focusableSelectors from 'focusable-selectors' function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }
我们现在可以更新我们的handleKeyDown
方法:
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }
保持专注
创建可访问对话框时经常被忽略的一件事是确保即使在页面失去焦点后焦点仍保留在对话框中。 可以这样想:如果对话框打开会发生什么? 我们聚焦浏览器的 URL 栏,然后再次开始 tabbing。 我们的焦点陷阱不起作用,因为它只在对话框内开始时才保留对话框内的焦点。
为了解决这个问题,我们可以在显示对话框时将焦点侦听器绑定到<body>
元素,并将焦点移动到对话框中的第一个可聚焦元素。
show () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.addEventListener('focus', this._maintainFocus, true) } hide () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.removeEventListener('focus', this._maintainFocus, true) } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } moveFocusIn () { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() }
打开对话框时要关注哪个元素不是强制的,它可能取决于对话框显示的内容类型。 一般来说,有两种选择:
- 聚焦第一个元素。
这就是我们在这里所做的,因为我们已经有了一个getFocusableChildren
函数,所以它变得更容易了。 - 聚焦关闭按钮。
这也是一个很好的解决方案,尤其是当按钮相对于对话框绝对定位时。 我们可以通过将关闭按钮作为对话框的第一个元素来方便地实现这一点。 如果关闭按钮位于对话框内容的流中,最后,如果对话框有很多内容(因此是可滚动的),则可能会出现问题,因为它会在打开时将内容滚动到末尾。 - 聚焦对话本身。
这在对话框库中不是很常见,但它也应该可以工作(尽管它需要向其中添加tabindex="-1"
以便这样做是可能的,因为默认情况下<div>
元素不可聚焦)。
请注意,我们检查对话框中是否存在具有autofocus
HTML 属性的元素,在这种情况下,我们会将焦点移到它而不是第一项。
恢复焦点
我们已经成功地在对话框中捕获了焦点,但是一旦它打开,我们就忘记了在对话框中移动焦点。 同样,我们需要将焦点恢复到对话框打开之前拥有它的元素。
在显示对话框时,我们可以首先保留对具有焦点的元素( document.activeElement
)的引用。 大多数情况下,这将是与打开对话框交互的按钮,但在以编程方式打开对话框的极少数情况下,它可能是其他东西。
show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }
隐藏对话框时,我们可以将焦点移回该元素。 如果元素以某种方式不再存在(或者如果它是 SVG),我们会用一个条件来保护它以避免 JavaScript 错误:
hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }
给出一个易于访问的名称
重要的是我们的对话框有一个可访问的名称,这就是它在可访问性树中的列出方式。 有几种方法可以解决它,其中一种是在aria-label
属性中定义一个名称,但是aria-label
有问题。
另一种方法是在我们的对话框中有一个标题(无论是否隐藏),并将我们的对话框与aria-labelledby
属性相关联。 它可能看起来像这样:
<div hidden aria-labelledby="my-dialog-title"> <div data-dialog-hide></div> <div> <h1>My dialog title</h1> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
我想我们可以让我们的脚本根据标题和诸如此类的存在动态应用这个属性,但我想说这很容易通过编写适当的 HTML 来解决,首先。 无需为此添加 JavaScript。
处理自定义事件
如果我们想对打开的对话框做出反应怎么办? 还是关门了? 目前没有办法做到,但是添加一个小事件系统应该不会太难。 我们需要一个注册事件的函数(我们称之为.on(..)
),以及一个取消注册事件的函数( .off(.. .off(..)
)。
class Dialog { constructor(element) { this.events = { show: [], hide: [] } } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } }
然后在显示和隐藏该方法时,我们将调用已为该特定事件注册的所有函数。
class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }
打扫干净
我们可能希望提供一种方法来清理对话框,以防我们使用完它。 它将负责注销事件侦听器,因此它们不会持续超过应有的时间。
class Dialog { destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } }
把它放在一起
import focusableSelectors from 'focusable-selectors' class Dialog { constructor(element) { this.element = element this.events = { show: [], hide: [] } this._show = this.show.bind(this) this._hide = this.hide.bind(this) this._maintainFocus = this.maintainFocus.bind(this) this._handleKeyDown = this.handleKeyDown.bind(this) element.setAttribute('hidden', true) element.setAttribute('role', 'dialog') element.setAttribute('aria-modal', true) const closers = [...element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) } show() { this.isShown = true this.previouslyFocused = document.activeElement this.element.removeAttribute('hidden') this.moveFocusIn() document.addEventListener('keydown', this._handleKeyDown) document.body.addEventListener('focus', this._maintainFocus, true) this.events.show.forEach(event => event()) } hide() { if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } this.isShown = false this.element.setAttribute('hidden', true) document.removeEventListener('keydown', this._handleKeyDown) document.body.removeEventListener('focus', this._maintainFocus, true) this.events.hide.forEach(event => event()) } destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) } moveFocusIn() { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } } function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } } function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }
包起来
那是相当的东西,但我们最终到达了那里! 再一次,我建议不要推出你自己的对话框库,因为它不是最直接的,并且错误可能会给辅助技术用户带来很大的问题。 但至少现在你知道它是如何工作的了!
如果您需要在项目中使用对话框,请考虑使用以下解决方案之一(请注意,我们也有可访问组件的完整列表):
- Vanilla JavaScript 实现:你的 a11y-dialog 或 Scott O'Hara 的 aria-modal-dialog。
- React 实现:react-a11y-dialog 来自你的真正,来自 Reach 框架的reach/dialog,或来自 Adobe 的@react-aria/dialog。 您可能会对这 3 个库的比较感兴趣。
- Vue 实现:Moritz Kroger 的 vue-a11y-dialog,Renato de Leao 的 a11y-vue-dialog。
这里有更多可以添加的东西,但不是为了简单起见:
- 通过
alertdialog
角色支持警报对话框。 请参阅有关警报对话框的 a11y-dialog 文档。 - 锁定对话框打开时滚动的能力。 请参阅有关滚动锁定的 a11y-dialog 文档。
- 支持原生 HTML
<dialog>
元素,因为它低于标准且不一致。 请参阅有关对话框元素的 a11y-dialog 文档和 Scott O'hara 的这篇文章,以获取有关为什么不值得麻烦的更多信息。 - 支持嵌套对话框,因为它是有问题的。 请参阅关于嵌套对话框的 a11y-dialog 文档。
- 关闭浏览器导航对话框的考虑。 在某些情况下,按下浏览器的后退按钮时关闭对话框可能是有意义的。