Accessible Modals, Part 2 - Focus Trap! Key Events!

In my last post, I wrote about how to create accessible modals following the W3C’s WAI-ARIA 1.1 guidelines. Part 1 covers ARIA attributes, focus management, and some of the most common accessibility concerns that come with creating a simple dialog modal. In this part, I will be covering how to listen to events within the modal so that when you are moving inside using Tab / Shift + Tab, you are following the following rules:

  • Users cannot interact with content outside an active dialog window, meaning they contain their own tab sequence regardless of the other outside element (there are some cases where clicking outside of the dialog will close the dialog)
  • Once the dialog is open, keyboard interactions should be as follows:
    • Tab moves forward to the next tabbable element
      • if you are at the end, it moves to the first
    • Shift + Tab moves to the previous tabbable element
      • if you are at the first, it moves to the last
    • Escape closes the dialog

I will be walking through this example to explain the implementation and I will be using the terms “modal” and “dialog” interchangably to refer to the same dialog modal UI pattern.

What Do We Need

First, let’s talk about what we need in order for us to handle the interactions based on the keys being pressed. We’re going to need to listen for key events within the dialog. The most logical way to do it in React would be on the uppermost parent modal div with onKeyDown and a function handler, handleKeyDown. We don’t want to listen for events on the document or window unless we absolutely have to.

<div
    className={classNames("spectrum-Dialog-wrapper spectrum-Underlay", {
    "is-open": isModalOpen
    })}
    onKeyDown={handleKeyDown}
>
const handleKeyDown = event => {
    // this function handles the events onKeyDown
};

Keeping the Focus Within the Dialog Modal

If you noticed while tabbing inside of the dialog, it is possible to tab way out of the window itself and cycle through the browser even. We don’t want that to happen since the dialog should contain its own tab sequence. We will need to trap the user’s focus within and control the tab order. n order to do that, we would have to take away the native event behavior that goes on in the event handler handleKeyDown, by adding an event.preventDefault(), but only if the user is not hitting Tab or Escape:

// event.keyCode
const Tab = 9;
const Escape = 27;

const handleKeyDown = event => {
    if (![Tab, Escape].includes(event.keyCode)) return;
    event.preventDefault();
};

This way, we can keep the default event handling for all the other keys like Enter or Space, but we’re going to implement Tab and Escape manually, since we are modifying their behavior for this modal dialog.

Escape Key

Let’s implement the Escape key, which is supposed to close the dialog. We’ll add to our handleKeyDown. It should check for an Escape keycode. When it is pressed, it calls closeDialog():

const handleKeyDown = event => {
    if (![Tab, Escape].includes(event.keyCode)) return;
    event.preventDefault();

    if (event.keyCode === Escape) {
        closeDialog();
        return;
    }
};

Tab / Shift + Tab Keys

Next, let’s add some conditionals for checking if the key(s) pressed are Tab, or Shift + Tab. The way pressing both Shift + Tab work for the event handler is that it will be registered as both event.keyCode (9) and event.shiftKey, so we would have to check for both Shift + Tab as a combination of Tab AND Shift. On the flip side, that means checking for Tab would be a Tab AND NOT Shift conditional, since Shift + Tab is still a Tab in the end. The long way of writing this would be:

if (event.keyCode === Tab) {
    if (event.shiftKey) {
        // this is Shift + Tab
    } else {
        // this is only Tab
    }
}

The cleaner way, if you want to avoid nesting too many if/else:

if (event.keyCode === Tab && event.shiftKey) {
    // placeholder for Shift + Tab logic
}

if (event.keyCode === Tab && !event.shiftKey) {
    // placeholder for Tab logic
}

Since the logic is never both true, it would be unnecessary to put them in an else if. We’ll add the logic after this one tidbit.

Keeping Tabs on All Focusable Elements ;)

In order to know if the user is at the first or the last focusable element, we would first need to keep tabs on all the elements that are focusable within the dialog. There are two ways that I can think of to go about this. It’s possible to use document.querySelectorAll() to find all the focusable elements (inputs, buttons, etc.) so we would have an iterable list of elements. The second way is using callback refs by putting a ref on all the focusable elements within the dialog and writing the callback function to add the element to the array of refs. I’m going go with the second way in this example since we are in React-land and refs are the preferred way when trying to access a DOM element. In the scenario where you have more than a few elements to focus on, adding ref to all the elements can be a pain, so I would recommend using element.querySelectorAll() in that case.

First, I would need to keep a ref for the array of refs that I will use to add my focusable elements into it:

const focusableELements = useRef([]);

Next, on my two buttons, I will simply add ref={setFocusableElements} with setFocusableElements being the callback function I will use to add to the array of all the focusable elements.

The two buttons in JSX:

<button
    className="spectrum-Button spectrum-Button--secondary"
    onClick={closeDialog}
    autoFocus
    ref={setFocusableElements}
>
    Also Agreed
</button>
<button
    className="spectrum-Button spectrum-Button--cta"
    onClick={closeDialog}
    ref={setFocusableElements}
>
    Agreed
</button>

The setFocusableElements callback function:

const setFocusableElements = element => {
    if (!element) return;
    if (focusableElements.current.find(focusableElement => focusableElement === element)) return;
    focusableElements.current = [...focusableElements.current, element];
};

Callback refs have the caveat of being called twice during an update, so I usually short-circuit the function if the element is null (the first run) and if the element already exists inside the array of refs.

Tab at the Last Element, Shift + Tab on the First Element

Now that we have all the refs in focusableElements, we can move on to implementing the logic for Tab and Shift + Tab. We said that if the user presses Tab on the last element, we’d bring them to the first, and if they presses Shift + Tab while they are on the first element, we would send them to the last element.

Let’s first define the first and last element:

const elements = focusableElements.current;
const firstElement = elements[0];
const lastElement = elements[elements.length - 1];

document.activeElement is an easiest way to get the currently focused element natively.

In the scenario of Shift + Tab, we would first check if the active element is the first element. If it is, we should focus on the last element. Otherwise, since that we took away all the default behavior logic, the default Shift + Tab on a non-first element should bring you back to the previous element. We can get the previous element relative to the currently focused element with document.activeElement.previousSibling. We just need to call .focus() accordingly.

Shift + Tab logic written in code:

if (event.keyCode === Tab && event.shiftKey) {
    if (document.activeElement === firstElement) {
        lastElement.focus();
    } else {
        document.activeElement.previousSibling.focus();
    }
}

For Tab, we would check if the active element is the last element. If it is, then we will focus on the first element. Otherwise, the default behavior we need to re-implement would be focusing on the next element, which can be found with document.activeElement.nextSibling.

Tab logic written in logic:

if (event.keyCode === Tab && !event.shiftKey) {
    if (document.activeElement === lastElement) {
        firstElement.focus();
    } else {
       document.activeElement.nextSibling.focus();
    }
}

Conclusion

Now you can write your own fully accessible, W3C compliant dialog modal given all the information, tools, tips, and techinques I’ve given you here and in Part 1! Hopefully this will equip you with the knowledge to write dialog modals with accessibility in mind.

Here’s the Code Sandbox that I worked with for this example: