How I approach keyboard accessibility for modals in React
Tags: a11y, javascript, react
Published: 2021-06-13 11:39:00

A couple of disclaimers before I start:
- This is not an article on how to manage modals in React, this article is about ensuring that modals are accessible for keyboard users.
- I am not an accessibility expert and, therefore, there may be things that could be better.

###Contents:###
- [The Basic Markup](#the-basic-markup)
- [Focus Trapping](#focus-trapping)
- [Closing The Modal](#closing-the-modal)

###TL;DR###
[Checkout my codepen](https://codepen.io/colette-wilson/full/NWpBPEy)

###The Basic Markup###
For this demonstration, I have used the `useState` React hook to set and unset the display state of my modal. Since my components are very simple it’s fairly easy to pass that state from the `Page` component containing the trigger button directly to the `Modal` component. In reality, you might use some sort of state management library to do this, I like [Zustand](https://github.com/pmndrs/zustand), but that’s off-topic. So, to start with my modal component looks like this;
```jsx
const Modal = ({ close modal }) => {

  return (
    <aside 
      className="modal"
      role="dialog"
      aria-modal="true"
    >
      <div className="modalInner">
        <button className="modalClose" type="button" onClick={closeModal}>
          <span className="visuallyHidden">Close modal</span>
        </button>
        <main className="modalContent">
          ...
        </main>
      </div>
    </aside>
  )
}
```
As you can see I have an `aside`, this acts as a fullscreen background, a `div` acting as the modal container, a `button` to close the modal, and a `main` element containing the content. The modal trigger button on the `Page` component simply sets the display state to true, this state is then used to display or hide the Modal component. The close button resets the display state to false.

This works perfectly well for mouse users so what’s the problem? Well, at the moment the modal opens on top of the page content without updating the DOMs active element, in other words, the focus will remain on the last focused item somewhere on the page behind the modal, leaving a keyboard user unable to interact with any elements inside the modal. Obviously not ideal so how can we make this more accessible?

###Focus Trapping###
The answer is to trap the focus in the modal while active. Essentially we need to add some Javascript that will ensure we add focus to the modal so the user can tab around and that they aren’t able to tab away from the modal without first closing it. 

The first thing I'm going to do is create a new `handleKeydown` function. This function will listen for a keypress and where appropriate invoke a further function that will perform a specific action, it looks like this;
```js
// map of keyboard listeners
const keyListenersMap = new Map([
  [9, handleTab],
])

const handleKeydown = evt => {
  // get the listener corresponding to the pressed key
  const listener = keyListenersMap.get(evt.keyCode)

  // call the listener if it exists
  return listener && listener(evt)
}
```
Here I have a map of key codes and corresponding functions. It's not necessary to structure things this way but I find it easier if I ever need to extend functionality later. `handleKeydown` listens to the key code of the key that's been pressed then gets and invokes the appropriate function from the map if there is one.

To start with the only key I'm tracking in my map has a key code of 9, the tab key. When tab is pressed the `handleTab` function should be invoked  which looks like this;
```js
const handleTab = evt => {
  let total = focusableElements.length
    
  // If tab was pressed without shift
  if (!evt.shiftKey) {  
    // If activeIndex + 1 larger than array length focus first element otherwise focus next element
    activeIndex + 1 === total ? activeIndex = 0 : activeIndex += 1

    focusableElements[activeIndex].focus()

    // Don't do anything I wouldn't do
    return evt.preventDefault()
  }

  // If tab was pressed with shift
  if (evt.shiftKey) {
    // if activeIndex - 1 less than 0 focus last element otherwise focus previous element
    activeIndex - 1 < 0 ? activeIndex = total - 1 : activeIndex -= 1

    focusableElements[activeIndex].focus()

    // Don't do anything I wouldn't do
    return evt.preventDefault()
  }
}
```
There's quite a lot going on here so let's break it down. The first line stores the total number of focusable elements as a variable. This just helps to make things a little more readable. `focusableElements` is a variable that has been set in a `useEffect` hook. We'll come to this later. Next, I want to detect whether or not the tab button was pressed in combination with shift. This will determine the direction we cycle through the elements. If just tab was pressed, no shift, we want to cycle forward. I'm using a ternary operator to set the index either to the next item in the array of focusable elements or, if there are no more elements in the array, back to the first element. This way we'll be able to tab infinitely without ever leaving the modal. `activeIndex` is a variable which on initial load is set to -1. And finally, I need to apply focus to the item in the `focusableElements` array at the correct index. The final line `return evt.preventDefault()` is a safety net just to ensure nothing unexpected happens. 

When tab is pressed with shift we need to repeat this cycle but in the other direction. So this time the ternary operator will set the index to the previous item in `focusableElements` unless we're at the beginning of the array in which case it will set the index to the last item in the array. 

To get everything hooked up I'm going to use 2 separate React `useEffect` hooks. The first will query for all the relevant elements within the modal and update the `focusableElements` variable. Note: The list of queried elements is not exhaustive, this is a small example and you may need to update the list depending on the content of the modal. The second will attach the event listener that will fire the  `handleKeydown` function described above;
```jsx
React.useEffect(() => {
  if (ref.current) {
    // Select all focusable elements within ref
    focusableElements = ref.current.querySelectorAll('a, button, textarea, input, select')
  }
}, [ref])

React.useEffect(() => {
  document.addEventListener('keydown', handleKeydown)

  return () => {
    // Detach listener when component unmounts
    document.removeEventListener('keydown', handleKeydown)
  }
}, [])
```
As you can see this is where I update the `focusableElements` variable. I'm using a ref which is attached to the div acting as the modal container so that I can collect all the elements within it. It's not strictly necessary to do this within the `useEffect` in my example since the content is static but in a lot of cases, the modal content may be dynamic in which case the variable will need to be updated whenever the component mounts. 

###Closing the Modal###
One thing I want to do is to extend my map of key codes to include detection for the escape key. Although there is a button specifically for closing the modal it's kind of a hassle to always have to cycle through all the elements to get to it. It would be nice to allow a user to exit early. So when the escape key is pressed I want to invoke the `handleEscape` function to close the modal. First I need to extend the `keyListenersMap` to include the additional key code, it now looks like this;
```js
const keyListenersMap = new Map([
  [27, handleEscape],
  [9, handleTab],
])
```
Then I need to add the new `handleEscape` function, which in this example look like this;
```js
const handleEscape = evt => {
  if (evt.key === 'Escape') closeModal()
}
```
Technically I could call `closeModal` from the map instead of wrapping it in another function but IRL I often need to do other things in here, for e.g. resetting a form or some other form of clean up. 

The final thing I need to do is return focus to the page when the modal closes. First I need to know which element is the currently active element at the time the modal is mounted. When the component mounts I want to set an `activeElement` variable, `const activeElement = document.activeElement` on my Modal component. When the component unmounts I simply want to return the focus to that same element. I'm going to update the same `useEffect` hook where my event listener is attached and detached. In the return function I'm simple going to add, `activeElement.focus()` so that the `useEffect` now looks like this;
```jsx
React.useEffect(() => {   
  document.addEventListener('keydown', handleKeydown)

  return () => {
    // Detach listener when component unmounts
    document.removeEventListener('keydown', handleKeydown)
    // Return focus to the previously focused element
    activeElement.focus()
  }
}, [])
```
There you have it. A modal that is keyboard friendly. 

---------

A couple of things not covered by this blog that you might consider adding as 'nice to haves'; 
- Stopping the background page scroll while the modal is active
- Closing the modal on a background click.