JavaScript:从头开始创建一个无障碍的对话框的教程

168 阅读12分钟

首先,不要在家里这样做。不要写你自己的对话框或一个库来做。有很多已经被测试过、审核过、使用过和重用过的,你应该更喜欢这些而不是你自己的。a11y-dialog是其中之一,但还有更多(列在本文的最后)。

让我把这篇文章作为一个机会,提醒大家在使用对话框时要谨慎。用它们来解决所有的设计问题是不切实际的,尤其是在移动设备上,但往往有其他的方法来克服设计问题。我们倾向于很快地陷入使用对话框,不是因为它们一定是正确的选择,而是因为它们很容易。他们把屏幕空间问题放在一边,用上下文切换来交换,这并不总是正确的权衡。重点是:在使用对话框之前要考虑它是否是正确的设计模式。

在这篇文章中,我们将编写一个小型的JavaScript库,用于从一开始就编写无障碍对话框(基本上是重新创建一个11y-dialog)。我们的目标是了解其中的内容。我们不打算处理太多的样式,只处理JavaScript部分。为了简单起见,我们将使用现代的JavaScript(如类和箭头函数),但请记住,这些代码可能无法在传统的浏览器中工作。

定义API

首先,我们要定义如何使用我们的对话脚本。我们将尽可能地保持它的简单性。我们给它提供我们的对话框的根HTML元素,而我们得到的实例有一个.show(..) 和一个.hide(..) 方法。

class Dialog {
  constructor(element) {}
  show() {}
  hide() {}
}

对话框的实例化

比方说,我们有以下的HTML。

<div id="my-dialog">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 id="my-dialog" hidden>This will be a dialog.</div>

用叠加法关闭

在对话框外点击应该关闭它。有几种方法可以做到这一点。一种方法是监听页面上的所有点击事件,并过滤掉发生在对话框内的事件,但这是相对复杂的做法。

另一种方法是监听覆盖层(有时称为 "背景")上的点击事件。覆盖层本身可以是一个简单的带有一些样式的<div>

所以当打开对话框时,我们需要在覆盖层上绑定点击事件。我们可以给它一个ID或某个类,以便能够查询它,或者我们可以给它一个数据属性。我倾向于这些行为钩子。让我们相应地修改我们的HTML。

<div id="my-dialog" 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 id="my-dialog" hidden>
  <div data-dialog-hide></div>
  <div>
    This will be a dialog.
    <button type="button" data-dialog-hide>Close</button>
  </div>
</div>

以逃亡收尾

不仅仅是在点击对话框以外的地方时应该隐藏,而且在按下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栏上,然后再次开始标签。我们的焦点陷阱是不会起作用的,因为它只在一开始就在对话框内的时候保留焦点。

为了解决这个问题,我们可以在对话框显示时,将焦点监听器绑定到<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()
}

在隐藏对话框时,我们可以将焦点移回到该元素上。我们用一个条件来保护它,以避免在该元素以某种方式不再存在时出现JavaScript错误(或者如果它是一个SVG)。

hide() {
  // … rest of the code
  if (this.previouslyFocused && this.previouslyFocused.focus) {
    this.previouslyFocused.focus()
  }
}

给予一个无障碍的名字

重要的是,我们的对话框有一个可访问的名称,这就是它在可访问性树中被列出的方式。有几种方法可以解决这个问题,其中之一是在aria-label 属性中定义一个名称,但aria-label 有问题。

另一种方法是在我们的对话框内有一个标题(无论是否隐藏),并且用aria-labelledby 属性将我们的对话框和它联系起来。它可能看起来像这样。

<div id="my-dialog" hidden aria-labelledby="my-dialog-title">
  <div data-dialog-hide></div>
  <div>
    <h1 id="my-dialog-title">My dialog title</h1>
    This will be a dialog.
    <button type="button" data-dialog-hide>Close</button>
  </div>
</div>

我想我们可以让我们的脚本根据标题和其他东西的存在来动态地应用这个属性,但我想说这可以通过编写适当的HTML来轻松解决。没有必要为此添加JavaScript。

处理自定义事件

如果我们想对对话框的打开做出反应呢?或者关闭?目前还没有办法做到这一点,但添加一个小的事件系统应该不会太难。我们需要一个注册事件的函数(就叫它.on(..) ),以及一个取消注册的函数(.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)
}

收尾工作

这是很重要的,但我们最终还是做到了再次,我建议不要推出你自己的对话框库,因为它不是最直接的,而且错误可能对辅助技术用户造成很大的问题。但至少现在你知道它是如何工作的了

如果你需要在你的项目中使用对话框,请考虑使用以下解决方案之一(善意地提醒你,我们也有一个全面的无障碍组件列表)。

这里有更多可以添加的东西,但为了简单起见,没有添加。