FE / a11y / js / react

Bump up modal accessibility with aria attributes and focus trap. Implementations in vanilla JavaScript and React.

2024 and here we are talking about modals, the most loved UI.
After all, how much you need to know about a window blocking all your actions?

Well, it turns out there are a few details to smooth a modal experience and get people through.
But first, let’s start from the basics.

Let’s dialog

A piece of UI floating over your page is called a dialog.
A dialog may allow you to interact with the rest of the page or block you.
If it does block you, it’s called a modal.

How do you tell assistive technology the user has in front a dialog?
You use the dialog html tag or the dialog role.

How do you tell assistive technology the dialog is a modal?
You use the the aria-modal attribute.

And, how do you tell assistive technology which dialog is which when you got many dialog in the page?
You give a name to your dialog using aria-label or aria-labelledby.

<div 
    role="dialog" 
    aria-modal="true"
    aria-label="Your dear modal">

In total, with role="dialog", aria-modal="true" and aria-label you got all the attributes in place for a modal.

The interactions

As the modal blocks the rest of the page, you need to cover two groups of interactions to avoid the user get lost: the open/close focus and the focus trap.

The open/close focus consists of get the focus within the modal when the modal opens up and bringing back the focus where it was when the modal closes.

In a way, this is just common sense.

After all, when the user opens up a modal the UI will use all the visual space.

If the focus remains on the button that triggered the interaction, then you will have a mismatch between the visual and the screen reader.

This is because the screen reader would be on the button but the visual would be the modal.

In addition, there’s no guarantee that on the next tab the user would gain focus within the modal; maybe for convenience, the modal was attached at the bottom of the body element and now you need to tab all the page through to get in.

Same story when the modal closes. You want to go back to where you were at. Because imagine you opened up a modal by mistake, you close it, and now you have to retab all the page through to be where you were.

Quite painful.

The focus trap instead consists of staying within the modal when the user keep tabbing and close the modal when the user press esc.

To get your head around this, imagine you were within the modal, you’re tabbing and you get out. Now you have the screen reader on the back of the page but visually you still have the modal.

You would still have the mismatch experience.

The esc interactions instead is just a safety net.

You found what you were looking for, or you opened up the modal by mistake, it doesn’t matter, you just want to get out. Now.

esc is there for you.

open/close interactions:

  1. focus within the modal when the modal opens up
  2. return the focus back where it was when the modal close

focus trap interactions:

  1. staying within the modal when the user keep tabbing
  2. close the modal when the user press esc

The code

Vanilla JavaScript

function modal() {

    /*


        DOM


     */

    const body = document.querySelector("body");
    const dial = document.querySelector("[data-id=dialog]");
    const btn  = document.querySelector("[data-id=btn]");
    const frst = document.querySelector("[data-id=first-itm]");
    const last = document.querySelector("[data-id=last-itm]");


    /*


        FUNCTIONS

        - setDisplay   := set the css display property.
                          This is used to show and hide the 
                          modal dialog. 

     */

    const setDisplay = 
            (value) => 
                dial.style.display = value;

    const show = 
            () => 
                setDisplay("block");

    const hide = 
            () => 
                setDisplay("none");


    /*


        HANDLERS

        - handleShowModal := show the modal and 
                             focus on the first item

        - handleEsc       := close modal on esc and get 
                             the focus back on the button

        - handleLastTab   := go back to the first item
        - handleShiftTab  := go back to the last item

    */

    const handleShowModal = 
            () => 
                (show(), frst.focus());

    const handleEsc =
            (e) =>
                e.key === 'Escape' 
                    && (hide(), btn.focus());

    const handleLastTab =
            (e) =>
                !e.shiftKey  
                    && e.key === 'Tab' 
                        && (e.preventDefault(), frst.focus());
    
    const handleShiftTab =
            (e) =>
                e.shiftKey  
                    && e.key === 'Tab' 
                        && (e.preventDefault(), last.focus());


    /*


        EVENTS


    */

    btn.addEventListener("click", handleShowModal);

    body.addEventListener("keydown", handleEsc);
    last.addEventListener("keydown", handleLastTab);
    first.addEventListener("keydown", handleShiftTab);

}


<!-- In your HTML -->

<button 
    data-id="btn"
    aria-controls="modal-01">Show Modal</button>

<div 
    data-id="dialog" 
    id="modal-01" 
    role="dialog"
    aria-modal="true"
    aria-label="My Modal"
    style="display:none">

    <button data-id="first-item">Do something</button>
   .
   .
   .

    <button data-id="last-item">Close</button>
</div>

handleShowModal and handleEsc take care of the open/close interactions, while handleLastTab and handleShiftTab take care of the focus trap.

We registered the callbacks on keydown because it’s the first KeyboardEvent that gets fired and we used KeyboardEvent.key and KeyboardEvent.shiftKey properties to detect the keyboard interactions.

React

import { useState, useRef, useEffect, KeyboardEvent as ReactKeyboardEvent, RefObject } from 'react';

function useFocus<T extends HTMLElement>(): [RefObject<T>, () => void] {
    const ref = useRef<T>(null);

    const focusIfPossible = 
            (): boolean => 
                (ref.current != null) 
                    && (ref.current.focus != null) 
                        && (ref.current.focus(), true);

    const createFocusAndRetry =
            (attempts: number) => 
                (): void => {
                    !focusIfPossible() 
                        && attempts 
                            && setTimeout(
                                    createFocusAndRetry(attempts - 1), 
                                    attempts
                                );
                };
    

    const setFocus = createFocusAndRetry(1);

    return [ref, setFocus];
}

const Modal = (props) => {

    /*


        HOOKS


     */
     
    const [frst, setFirstFocus] = useFocus<HTMLAnchorElement>();
    const [last, setLastFocus]  = useFocus<HTMLButtonElement>();
    const [btn, setBtnFocus]    = useFocus<HTMLButtonElement>();


    /*


        STATES


     */
     
    const [isExpanded, setExpanded] = useState(false);


    /*


        FUNCTIONS


     */

    const handleOpen = 
            () => 
                (setExpanded(true), setFirstFocus());

    const handleLastTab =
            (e: ReactKeyboardEvent) =>
                !e.shiftKey  
                    && e.key === 'Tab' 
                        && (e.preventDefault(), setFirstFocus());
    
    const handleShiftTab =
            (e: ReactKeyboardEvent) =>
                e.shiftKey  
                    && e.key === 'Tab' 
                        && (e.preventDefault(), setLastFocus());

                        
    /*


        EFFECTS


     */

    useEffect(() => {
        const handleEsc =
                (e: KeyboardEvent) =>
                    e.key === 'Escape' 
                        && isExpanded
                            && (setExpanded(false), setBtnFocus());

        document.querySelector("body")?.addEventListener('keydown', handleEsc);

        return () => {
            document.querySelector("body")?.removeEventListener('keydown', handleEsc);
        };
    }, [setBtnFocus, isExpanded]);

    return (
        <div>
            <button 
                onClick={handleOpen}
                aria-expanded={isExpanded} 
                aria-owns="modal"
                ref={btn}>Show Modal</button>

            {
                isExpanded && 
                    <div 
                        data-id="dialog" 
                        id="modal"
                        role="dialog">
                            <a 
                              href="/" 
                              ref={frst} 
                              onKeyDown={handleShiftTab}>First item link example</a>
                        
                             .
                             .
                             .

                            <button 
                              ref={last} 
                              onKeyDown={handleLastTab}>Last item button example</button>
                    </div>
            }
        </div>
    );
}

The useFocus custom hook exists as ref.current init as null. As such, when we try to use ref.current.focus() TypeScript complains as ref.current may be null.

And so, setFocus checks for ref.current not being null and retry the focus if the first attempt fails.

Functions like handleOpen wouldn’t work without the retry functionality.

This is because when isExpanded is false the whole modal is not rendered and the frst ref is null. Thus focus wouldn’t trigger.

The only thing I’m uneasy by is the use of setTimeout as it feels like cheating.

I wish there was a cleaner API to use from React.

As an alternative, we could extract the dialog in another component with a useEffect and an onLoad prop. Unfortunately, with this method, the hook would not be portable as you would need a similar component all the time.

const Dialog = ({children, onLoad}: {children: ReactNode, onLoad: () => void}) => {
    useEffect(() => onLoad(), [onLoad]);

    return (<>{children}</>);
}

Next, the import rename KeyboardEvent as ReactKeyboardEvent.
This is there because we did use React events and DOM events at the same time and as they have the same name, one has to be renamed.

Note that to add support for the DOM events you’ll need to add the dom library within the compilerOptions property in tsconfig.json.

# tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "lib": ["dom"],
        .
        .
        .
    },
}

Summary

To conclude, we saw what’s a dialog, a modal dialog and we explored the use of role="dialog", aria-modal="true" and aria-label.

Then, we looked at how open/close interactions and focus trap help with accessibility. We ended up with two code implementations, one in vanilla JavaScript and one in React.

Along the way, we saw how to use the KeyboardEvent properties, shiftKey and key and the KeyboardEvent from the DOM within React.

If you are interested in the subject, I would recommend reading <dialog>: The Dialog element, Dialog (Modal) Pattern and the previous post on aria-expanded.

Comments