Accessibility is an aspect of web development that is often overlooked. I would argue that it is as vital as overall performance and code reusability. We justify our endless pursuit of better performance and responsive design by citing the users, but ultimately these pursuits are done with the user’s device in mind, not the user themselves and their potential disabilities or restrictions.
A responsive app should be one that delivers its content based on the needs of the user, not only their device.
Luckily, there are tools to help alleviate the learning curve of accessibility-minded development. For example, GitHub recently released their accessibility error scanner, AccessibilityJS and Deque has aXe. This article will focus on a different one: Ally.js, a library simplifying certain accessibility features, functions, and behaviors.
One of the most common pain points regarding accessibility is dialog windows.
There’re a lot of considerations to take in terms of communicating to the user about the dialog itself, ensuring ease of access to its content, and returning to the dialog’s trigger upon close.
A demo on the Ally.js website addresses this challenge which helped me port its logic to my current project which uses React and TypeScript. This post will walk through building an accessible dialog component.
Before getting into the use of Ally.js, let’s take a look at the initial setup of the project. The project can be cloned from GitHub or you can follow along manually. The project was initiated using create-react-app in the terminal with the following options:
We begin this component by creating the Props interface. This will allow us to pass in the dialog’s title and description, two important pieces for accessibility. We will also pass in a close method, which will refer back to the toggleDialog method from the App container. Lastly, we create the functional ref to the newly created dialog window to be used later.
The following styles can be applied to create the dialog window appearance.
What we’ve done here is add the methods checkForDialog and getDialog.
Inside of the render method, which runs any time the state updates, there is a call to run checkForDialog. So upon clicking the button, the showDialog state will update, causing a re-render, calling checkForDialog again. Only now, showDialog is true, triggering getDialog. This method returns the Dialog component we just built to be rendered onto the screen.
The above sample includes a Button component that has not been shown.
Now, we should have the ability to open and close our dialog. So let’s take a look at what problems exist in terms of accessibility and how we can address them using Ally.js.
Using only your keyboard, open the dialog window and try to enter text into the form. You’ll notice that you must tab through the entire document to reach the elements within the dialog. This is a less-than-ideal experience. When the dialog opens, our focus should be the dialog – not the content behind it. So let’s look at our first use of Ally.js to begin remedying this issue.
Ally.js is a library providing various modules to help simplify common accessibility challenges. We will be using four of these modules for the Dialog component.
The .popup-outer-container acts as a mask that lays over the page blocking interaction from the mouse. However, elements behind this mask are still accessible via keyboard, which should be disallowed. To do this the first Ally module we’ll incorporate is maintain/disabled. This is used to disable any set of elements from being focussed via keyboard, essentially making them inert.
Unfortunately, implementing Ally.js into a project with TypeScript isn’t as straightforward as other libraries. This is due to Ally.js not providing a dedicated set of TypeScript definitions. But no worries, as we can declare our own modules via TypeScript’s types files.
In the original screenshot showing the scaffolding of the project, we see a directory called types. Let’s create that and inside create a file called `global.d.ts`.
Inside of this file let’s declare our first Ally.js module from the esm/ directory which provides ES6 modules but with the contents of each compiled to ES5. These are recommended when using build tools.
declare module 'ally.js/esm/maintain/disabled';
With this module now declared in our global types file, let’s head back into the Dialog component to begin implementing the functionality.
We will be adding all the accessibility functionality for the Dialog to its component to keep it self-contained. Let’s first import our newly declared module at the top of the file.
import Disabled from 'ally.js/esm/maintain/disabled';
The goal of using this module will be once the Dialog component mounts, everything on the page will be disabled while filtering out the dialog itself.
So let’s use the componentDidMount lifecycle hook for attaching any Ally.js functionality.
When the component mounts, we store the Disabled functionality to the newly created component property disableHandle. Because there are no defined types yet for Ally.js we can create a generic Handle interface containing the disengage function property. We will be using this Handle again for other Ally modules, hence keeping it generic.
By using the filter property of the Disabled import, we’re able to tell Ally.js to disable everything in the document except for our dialog reference.
Lastly, whenever the component unmounts we want to remove this behaviour. So inside of the componentWillUnmount hook, we disengage() the disableHandle.
We will now follow this same process for the final steps of improving the Dialog component. We will use the additional Ally modules:
Let’s update the `global.d.ts` file so it declares these additional modules.
As well as import them all into the Dialog component.
import Disabled from 'ally.js/esm/maintain/disabled'; import TabFocus from 'ally.js/esm/maintain/tab-focus'; import FirstTab from 'ally.js/esm/query/first-tabbable'; import Key from 'ally.js/esm/when/key';
After disabling the document with the exception of our dialog, we now need to restrict tabbing access further. Currently, upon tabbing to the last element in the dialog, pressing tab again will begin moving focus to the browser’s UI (such as the address bar). Instead, we want to leverage tab-focus to ensure the tab key will reset to the beginning of the dialog, not jump to the window.
We follow the same process here as we did for the disabled module. Let’s create a focusHandle property which will assume the value of the TabFocus module import. We define the context to be the active dialog reference on mount and then disengage() this behaviour, again, when the component unmounts.
At this point, with a dialog window open, hitting tab should cycle through the elements within the dialog itself.
Now, wouldn’t it be nice if the first element of our dialog was already focused upon opening?
First Tab Focus
Leveraging the first-tabbable module, we are able to set focus to the first element of the dialog window whenever it mounts.
Within the componentDidMount hook, we create the element variable and assign it to our FirstTab import. This will return the first tabbable element within the context that we provide. Once that element is returned, calling element.focus() will apply focus automatically.
Now, that we have the behavior within the dialog working pretty well, we want to improve keyboard accessibility. As a strict laptop user myself (no external mouse, monitor, or any peripherals) I tend to instinctively press esc whenever I want to close any dialog or popup. Normally, I would write my own event listener to handle this behavior but Ally.js provides the when/key module to simplify this process as well.
Again, we provide a Handle property to our class which will allow us to easily bind the esc functionality on mount and then disengage() it on unmount. And like that, we’re now able to easily close our dialog via the keyboard without necessarily having to tab to a specific close button.
Lastly (whew!), upon closing the dialog window, the user’s focus should return to the element that triggered it. In this case, the Show Dialog button in the App container. This isn’t built into Ally.js but a recommended best practice that, as you’ll see, can be added in with little hassle.
What has been done here is a property, focusedElementBeforeDialogOpened, has been added to our class. Whenever the component mounts, we store the current activeElement within the document to this property.
It’s important to do this before we disable the entire document or else document.activeElement will return null.
Then, like we had done with setting focus to the first element in the dialog, we will use the .focus() method of our stored element on componentWillUnmount to apply focus to the original button upon closing the dialog. This functionality has been wrapped in a type guard to ensure the element supports the focus() method.
Now, that our Dialog component is working, accessible, and self-contained we are ready to build our App. Except, running yarn test or yarn build will result in an error. Something to this effect:
[path]/node_modules/ally.js/esm/maintain/disabled.js:21 import nodeArray from '../util/node-array'; ^^^^^^ SyntaxError: Unexpected token import
Despite Create React App and its test runner, Jest, supporting ES6 modules, an issue is still caused with the ESM declared modules. So this brings us to our final step of integrating Ally.js with React, and that is the babel-polyfill package.
All the way in the beginning of this post (literally, ages ago!), I showed additional packages to install, the second of which being babel-polyfill. With this installed, let’s head to our app’s entry point, in this case ./src/index.tsx.
At the very top of this file, let’s import babel-polyfill. This will emulate a full ES2015+ environment and is intended to be used in an application rather than a library/tool.
With that, we can return to our terminal to run the test and build scripts from create-react-app without any error.