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:
- when we click again on the dropdown button (toggle)
- when we focus out of the dropdown, and
- 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
- The DOM Node Interface has a contains method, that can be used to check if an element is inside another
blur
,focus
,focusout
andfocusin
events have a FocusEvent.relatedTarget property which represents the next target. In the case ofblur
andfocusout
, it represents the next element gaining focus. Note, in some casesrelatedTarget
can benull
.- We attached the
click
event to thebody
element to catch all theclicks
, even theclicks
outside of the dropdown. This is possible becauseclick
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.