FE / a11y / js / react

Boost your dropdown accessibility with aria-expanded. Implementations in vanilla JavaScript and React

You’re making a new shiny dropdown.
You’ve done with the UI and accessibility is next on your list.

You start with aria-expanded.
You know more or less how it should work,
aria-expanded="true" if the dropdown is open, aria-expanded="false" if the dropdown is closed.

Or at least that’s what you thought.
Because as you’re going to stroke the first key, you froze.

Was that really all? Which JavaScript events to use and which interactions will trigger the open and closed states?

What’s aria-expanded anyway

aria-expanded is an accessibility attribute and usually, it comes with another attribute called aria-controls.

These two attributes tell the screen reader if the dropdown is open or closed and which element controls the toggle.

Basically, you’re saying to the screen reader, “Look, this button controls this random HTML at the end of the page. Click it, and the random HTML will expand showing you more info.”

Being in and being out

We know our dropdown will be in an expanded or collapsed state, but what are the user interactions that will trigger these states? Or said in another way, what should the user do to have the dropdown open and close?

So, let’s begin defining the dropdown expanded and collapsed interactions.
Shall we?!

We have one way to open the dropdown, that is when we click the dropdown button and the dropdown expands.
This is the only interaction for the expanded state.

For the collapsed state instead, we have three interactions, three ways of closing the dropdown:

  1. when we click again on the dropdown button (toggle)
  2. when we focus out of the dropdown, and
  3. when we click on anything that is not the dropdown itself.

Now, we can do a further step, that is grouping the interactions in internal and external.

The internal group will contain the interactions that happen within the dropdown itself. Like with the toggle button.

While the external group will contain the interactions that happen on anything outside the dropdown, like when the dropdown loses focus, with an external element gains focus, or when we click anything that is outside the dropdown.

Even if this is not obvious, this division will help with the code later on.
This is because this grouping leads into two chunks of code. The toggle, which will cover the basic function to open and close the dropdown and the “check if we’re in the dropdown” function, which will be the main function to collapse the dropdown when we move to some other element.

I hear you

In JavaScript, fire events means, “broadcasting that something, an event, has happened”, while listening to an event means, “waiting for something to happen”.

We can focus now on the JavaScript events.

To cover the internal interaction, we attach a click event listener to the dropdown button. This will take care of the toggle.

For the external interaction, we use a focusout event listener on the most external dropdown element. This will catch the blur events coming from within the dropdown.

While to detect any click on the page, included outside the dropdown, we attach a click event listener to the body element.

focusout vs blur

We did use focusout instead of blur because blur doesn’t bubble up.

What’s this bubble-up thing?!

Nothing crazy. An event bubbling is when you have two elements, one inside the other, and the external element is able to access all the events of the internal element.

The code

We’re ready for the code.

Vanilla JavaScript

function dropDown() {

    /*


        DOM


     */

    const body = document.querySelector("body");
    const drdo = document.querySelector("[data-id=dropdown]");
    const dial = document.querySelector("[data-id=dialog]");
    const btn  = document.querySelector("[data-id=btn]");


    /*


        FUNCTIONS

        - setExpanded  := update aria-expanded.
        - setDisplay   := set the css display property.
                          This is used to show and hide the 
                          dropdown dialog. 
        - isInDropDown := check if an element is within 
                          the dropdown.

     */

    const setExpanded = 
            (state) => 
                btn.ariaExpanded = state;

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

    const show = 
            () => 
                ( setExpanded("true"), setDisplay("block") );

    const hide = 
            () => 
                ( setExpanded("false"), setDisplay("none") );

    const isInDropDown = 
            (elem) => 
                drdo.contains(elem);


    /*


        HANDLERS


    */

    const handleToggle = 
            () => 
                btn.ariaExpanded === "true" 
                    ? hide() 
                    : show();

    const handleClick = 
            (e) => 
                !isInDropDown(e.target) 
                    && hide();
                
    const handleFocusOut = 
            (e) => 
                e.relatedTarget 
                    && !isInDropDown(e.relatedTarget) 
                        && hide();


    /*


        EVENTS


    */

    drdo.addEventListener("focusout", handleFocusOut);
    btn.addEventListener("click", handleToggle);
    body.addEventListener("click", handleClick);
}


<!-- In your HTML -->

<div data-id="dropdown">
    <button 
        data-id="btn"
        aria-expanded="false" 
        aria-controls="widget">Show widget</button>

    <div 
        data-id="dialog" 
        id="widget" 
        role="dialog"
        style="display:none">
       .
       .
       .
    </div>
</div>

Key concepts

  1. The DOM Node Interface has a contains method, that can be used to check if an element is inside another
  2. blur, focus, focusout and focusin events have a FocusEvent.relatedTarget property which represents the next target. In the case of blur and focusout, it represents the next element gaining focus. Note, in some cases relatedTarget can be null.
  3. We attached the click event to the body element to catch all the clicks, even the clicks outside of the dropdown. This is possible because click bubble-up.

relatedTarget can be null

Many elements can’t have focus, which is a common reason for relatedTarget to be null. relatedTarget may also be set to null for security reasons, like when tabbing in or out of a page.

React

import { useState, useRef, useEffect, FocusEvent } from "react";

const DropDown = (props) => {
    
    /*


        REFS


     */
     
    const drdo = useRef<HTMLDivElement>(null);


    /*


        STATES


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


    /*


        FUNCTIONS


     */
     
    const isParentChild = 
            (parent: HTMLElement, child: Node | null) => 
                parent.contains(child);

    const handleToggle = 
            () => 
                setExpanded(!isExpanded);

    const handleFocusOut = 
            (e: FocusEvent) => 
                !isParentChild(drdo.current as HTMLElement, e.relatedTarget as Node) 
                    && setExpanded(false);


   /*


        EFFECTS


    */
     
    useEffect(() => {
        const handleClick = 
                (e: MouseEvent) => 
                    !isParentChild(drdo.current as HTMLElement, e.target as Node) 
                        && setExpanded(false);


        if (drdo == null || drdo.current == null) {
            return;
        }

        document.querySelector("body")?.addEventListener('click', handleClick);

        return () => {
            document.querySelector("body")?.removeEventListener('click', handleClick);
        };
    }, [drdo]);

    return (
        <div onBlur={handleFocusOut} ref={drdo}>
            <button 
                onClick={handleToggle}
                aria-expanded={isExpanded} 
                aria-controls="widget">Show widget</button>

            {
                isExpanded && 
                    <div id="widget" role="dialog">
                     .
                     .
                     .
                    </div>
            }
        </div>
    );
};

Notes

In this case, we used onBlur instead of onFocusOut because All React events are normalized to bubble.

This means that if we were trying to use onFocusOut React would rise a warning and suggest to use onBlur.

For reference, this is the warning you get when you use onFocusOut:

Warning: React uses onFocus and onBlur instead of onFocusIn and onFocusOut. All React events are normalized to bubble, so onFocusIn and onFocusOut are not needed/supported by React.

Also, notice the MouseEvent within the useEffect. That event is not from React. It’s a plain old browser DOM event. If you try to import the MouseEvent from React you’ll get an error as React events are meant for the React virtual DOM, and they are not always compatible with the browser DOM APIs.

TypeScript config
In the example we did use TypeScript. By default, TypeScript is unaware of the environment the code generated will run on.

It could be Node.js or it could be the browser, TypeScript doesn’t know, you have to tell.

So, to make TypeScript support the browser DOM JS APIs, you’ll need to edit tsconfig.json, adding the dom library within the compilerOptions property.

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

Summary

Time to wrap up.

We saw the aria-expanded and aria-controls attributes, then we listed the expanded and collapsed interactions, followed by the JavaScript events to use.

We concluded with two dropdown code implementations in vanilla JavaScript and React.

In the process, we saw the Node contains method, the concept of event bubbling, the FocusEvent.relatedTarget property, some React events normalized to bubble, and the browser DOM MouseEvent within the React context.

If you’re interested in the subject, I would recommend exploring WAI-ARIA: Menu Button Pattern, MDN aria-expanded, MDN aria-controls and Event Bubbling.