Quick summary ↬

Dialogues are everywhere in modern interface design (for better or for worse), and yet many of them are inaccessible to assistive technologies. In this article, we will explore how you can create a short script to create accessible dialogue.

In the first place, do not do it at home. Do not write your own dialogue or a library to do so. There are already many of them that have been tested, audited, used and reused, and you should prefer it over your own. a11y dialog is one of them, but there is more (at the end of this article).

Let me use this post as an opportunity to remind you all be careful when using dialogue. It is tentalizing to address all design issues with it, especially on mobile devices, but there are often other ways to overcome design issues. We tend to use dialogue quickly, not because it’s necessarily the right choice, but because it’s easy. They set aside screen problems by swapping them for contextual changes, which are not always the right consideration. The point is: consider whether a dialogue is the right design pattern before using it.

In this post we are going to write a small JavaScript library for authoring accessible dialogs from the beginning (essentially recreating a11y dialogue). The goal is to understand what goes into it. We are not going to deal too much with styling, just the JavaScript section. We will use modern JavaScript for simplicity (such as classes and arrow functions), but keep in mind that this code may not work in previous browsers.

  1. Define the API
  2. Install the dialog
  3. Show and hide
  4. Close with cover
  5. Close with escape
  6. Focus selection
  7. Stay focused
  8. Restoring focus
  9. Give an accessible name
  10. Managing personal opportunities
  11. Clean
  12. Bring it all together
  13. Finish

Defining the API

First, we want to define how we are going to use our dialogue script. We’ll keep it as simple as possible to begin with. We give it the main HTML element for our dialogue, and the case we get has a .show(..) en a .hide(..) method.

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

Install the dialog

Suppose we have the following HTML:

<div id="my-dialog">This will be a dialog.</div>

And we install our dialogue like this:

const element = document.querySelector('#my-dialog')
const dialog = new Dialog(element)

There are a few things we need to do under the hood if you are installing it:

  • Hide it so that it is hidden (hidden).
  • Mark it as a dialogue for assistive technologies (role="dialog").
  • Disable the rest of the page when it’s open (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)
}

Note that we could have added these three features in our initial HTML to not have to add them with JavaScript, but in this way it is out of sight and out of mind. Our writing can make things work the way they should, whether we have thought about adding all our attributes or not.

Show and hide

We have two methods: one to show the dialogue and one to hide it. These methods will (for the time being) not do much, except to hidden feature on the root element. We are also going to maintain a boolean over the institution to be able to quickly judge whether the dialogue is displayed or not. This will come in handy later.

show() {
  this.isShown = true
  this.element.removeAttribute('hidden')
}

hide() {
  this.isShown = false
  this.element.setAttribute('hidden', true)
}

To prevent the dialog from being visible before enabling JavaScript and hiding it by adding the attribute, it might be interesting to add hidden to the dialog directly in the HTML from the beginning.

<div id="my-dialog" hidden>This will be a dialog.</div>

Close with cover

If you click outside the dialog, you must close it. There are different ways to do this. One way may be to listen to all the click events on the page and filter out what is happening within the dialog, but this is relatively complex to do.

Another approach would be to listen to click events on the cover (sometimes called “background”). The cover itself can be as simple as a <div> with some styles.

So when we open the dialog, we need to bind click events on the cover. We can give it an ID or a certain class to query it, or we can give it a given attribute. I tend to favor it for brackets. Let’s edit our HTML accordingly:

<div id="my-dialog" hidden>
  <div data-dialog-hide></div>
  <div>This will be a dialog.</div>
</div>

Now we can query the elements with the data-dialog-hide feature within the dialog and give them a click listener that hides the dialog.

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))
}

The beauty of such a thing is that we can use the same for the lock button of the dialog box.

<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>
More to jump! Read more below ↓

Closing with escape

The dialog should not only be hidden when you click out there, but it should also be hidden when you press Esc. When we open the dialog, we can bind a keyboard listener to the document and remove it when it closes. This way, it just listens to keystrokes while the dialog is open instead of all the time.

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()
}

Capture of focus

These are the good things now. Capturing the focus within the dialogue is at the heart of the whole thing and should be the most complicated part (though probably not as complicated as you might think).

The idea is quite simple: when the dialogue is open, we listen to Tab pressure. If you press Tab on the last focusable element of the dialogue, we shift the focus programmatically to the first. If you press Move + Tab on the first focusable element of the dialogue, we move it to the last one.

The function can look like this:

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()
  }
}

The next thing we need to find out is how to get all the focusable elements of the dialogue (getFocusableChildren). We need to investigate all the elements that can be theoretically focused, and then we need to make sure that they are effective.

The first part can be done with focus voters. This is a small, small package I wrote and offers this range of selectors:

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^="-"])',
]

And that’s enough to get you 99% there. We can use these selectors to find all focusable elements, and then we can look at each one of them to make sure it’s really visible on the screen (and not hidden or anything like that).

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)
}

We can now handleKeyDown method:

handleKeyDown(event) {
  if (event.key === 'Escape') this.hide()
  else if (event.key === 'Tab') trapTabKey(this.element, event)
}

Retaining focus

One thing that is often overlooked when creating accessible dialogue is to ensure that the focus remains even within the dialogue after the page has lost focus. Think of it this way: what happens if the dialogue is open once? We focus on the URL bar of the browser and then start again with the tab. Our focus trap is not going to work because it retains the focus within the dialogue only to start inside.

To solve the problem, we can provide a focus listener to the <body> element when the dialog box is displayed, and move the focus to the first focusable element in the dialog.

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()
}

Which element to focus on when you open the dialog is not enforced, and this may depend on the type of content that the dialog displays. Generally, there are a few options:

  • Focus on the first element.
    This is what we do here, because it’s made easier by the fact that we already have a getFocusableChildren function.
  • Focus on the lock button.
    This is also a good solution, especially if the button is placed relative to the dialog. We can make this easy by placing our lock button as the first element of our dialogue. If the button includes the flow of the dialogue content, at the very end, it can be a problem if the dialogue contains a lot of content (and thus can be moved), because it will move the content open towards the end.
  • Focus the dialogue yourself.
    It’s not very common among dialog libraries, but it should work as well (though it is necessary to add it) tabindex="-1" to it so that it is possible since a <div> element is not standard focusable).

Note that we are looking for an element with the autofocus HTML attribute within the dialog, in which case we will shift the focus there instead of the first item.

Restoring focus

We managed to successfully capture the focus within the dialogue, but we forgot to shift the focus within the dialogue as soon as it is opened. Similarly, we need to bring the focus back to the element it had before the dialogue was open.

If you show the dialog, we can start by keeping a reference to the element that has the focus (document.activeElement). This is usually the button that was contacted to open the dialog, but in rare cases a dialog can be opened programmatically, something else.

show() {
  this.previouslyFocused = document.activeElement
  // … rest of the code
  this.moveFocusIn()
}

If you hide the dialog, we can move the focus back to the element. We protect it with the proviso that we avoid a JavaScript error if the element no longer exists (or if it was an SVG):

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

Give an accessible name

It is important that our dialogue has an accessible name, and it is listed in the accessibility tree. There are several ways to address this, one of which is to include a name in the aria-label feature, but aria-label have problems.

Another way is to have a title in our dialog (whether or not hidden), and link our dialog to the aria-labelledby characteristic. It can look like this:

<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>

I think we can make our script apply this feature dynamically based on the presence of the title and whatnot, but I would say that it can be solved just as easily by writing HTML first. It is not necessary to add JavaScript for this.

Managing personal opportunities

What if we want to respond to the open dialogue? Or closed? There is currently no way to do this, but adding a small event system should not be too difficult. We need a function to record events (let’s call it .on(..)), and a function to register them (.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)
  }
}

If we then show and hide the method, we list all functions registered for the event in question.

class Dialog {
  show() {
    // … rest of the code
    this.events.show.forEach(event => event())
  }

  hide() {
    // … rest of the code
    this.events.hide.forEach(event => event())
  }
}

Clean

We might want to offer a method to clean up a dialogue if we use it. This will be responsible for the unregisteration of listeners so that they do not last longer than they should.

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))
  }
}

Bringing it all together

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)
}

Finish

It was quite something, but finally we got there! Again, I would discourage you from introducing your own dialog library, as this is not the simplest way, and that errors can be very problematic for utilities users. But now you at least know how it works under the hood!

If you need to use dialogue in your project, consider using one of the following solutions (kind reminder that we have our complete list of accessible components also):

Here are some things that could be added, but not for the sake of simplicity:

Smashing Editorial
(vf, hy)



Source link