The title of this article refers to one interaction that goes by many (sometimes confusing) names. You click a button that causes some previously hidden content to appear on the screen.
Examples may include clicking on a profile picture to see various profile options or having a multi-level navigation with top level buttons and nested links.
Do you call it a dropdown, a popup, a menu, a disclosure? A dropdown menu? A popup menu? Whatever you call it, let's look at how to make it more accessible.
The disclosure pattern
In this article, I'm going to refer to it as a disclosure. Why? Because it might not always drop down below a button and menu patterns are complicated and usually not needed on websites.
In this pattern, a user presses a button and something that was hidden is now visible - it is disclosed. Clicking away will hide the content again.
Our example
In this example, I'm going to be using a fairly basic multi-level navigation to demonstrate what's needed above and beyond semantic HTML to make the disclosure pattern accessible.
Here's what our navigation looks like:
It's a simple navigation with three sections: Our Services, About and Contact Us. Visually, we can see that Our Services and About are marked with down arrows suggesting that pressing these will open a disclosure. The lack of a down arrow on Contact Us suggests instead that we will be taken directly to that section.
The arrows also give us clues about the semantic HTML we should use. Pressing Our Services or About performs an action by opening a disclosure on the same page and therefore they should be buttons. Pressing Contact Us takes us to a different part of the site and should therefore be a link.
The HTML
Let's take a look at the HTML and the necessary attributes needed to get this working accessibly:
<nav>
<ul class="nav-list">
<li class="nav-group">
<button id="navItem1" class="navItem" aria-expanded="false" aria-controls="disclosure1">Our Services ▼</button>
<ul class="disclosure hidden" id="disclosure1">
<li>
<a href="#">Dropdown 1 - item 1</a>
</li>
<li>
<a href="#">Dropdown 1 - item 2</a>
</li>
<li>
<a href="#">Dropdown 1 - item 3</a>
</li>
</ul>
</li>
<li class="nav-group">
<button id="navItem2" class="navItem" aria-expanded="false" aria-controls="disclosure2">About ▼</button>
<ul class="disclosure hidden" id="disclosure2">
<li>
<a href="#">Our culture</a>
</li>
<li>
<a href="#">Partnerships</a>
</li>
</ul>
</li>
<li class="nav-group">
<a href="#">Contact Us</a>
</li>
</ul>
</nav>
We use the nav
element around the entire component to indicate that this is a navigation. Within that we have an unordered list ul
with three list items: the Our Services and About buttons using the <button>
element and the Contact Us link using the <a>
element.
The buttons require more than just HTML in order to be accessible. First we add an attribute aria-expanded="false"
. This let's screen reader users know that the disclosure attached to the button is not currently visible. We can change it to true with JavaScript when a user clicks on the button.
Each button also has aria-controls="disclosure-id"
which links to the id attribute of the disclosure that belongs to it. This helps to identify which button controls each disclosure.
Nested inside each list item for the Our Services and About sections is another unordered list of the links that will be disclosed when the button is pressed.
In their initial state, the lists have the hidden
attribute which means they are not announced to screen reader users and it is not possible to access them with a keyboard whilst closed. This attribute will also be controlled with JavaScript when the button is pressed and the disclosure becomes visible.
Adding the functionality
Now let's make it work. There's several things we want our component to do:
- It should show the disclosure when the button is clicked once
- It should hide the disclosure if the same button is pressed a second time
- An open disclosure should close when the user activates another disclosure
- The disclosure should close if a user presses the Escape key
- The disclosure should close if a user tabs away from the last element to another element on the page
Opening and Closing the disclosure on button press
In order to open the disclosure we need to remove the hidden
attribute and change aria-expanded
to true. To make sure only one disclosure is open at once we loop over all of the buttons and make sure everything is closed first. Then we open only the one that was most recently pressed.
When we close the disclosure, we add back the hidden
attribute and change aria-expanded
back to false.
const navButtons = document.querySelectorAll(".navItem");
const disclosures = document.querySelectorAll(".disclosure");
function openNavigation(button) {
button.setAttribute("aria-expanded", "true");
// The ul is a direct sibling to the button
const disclosure = button.nextElementSibling;
disclosure.classList.remove("hidden");
}
function closeNavigation(button) {
button.setAttribute("aria-expanded", "false");
const disclosure = button.nextElementSibling;
disclosure.classList.add("hidden");
}
function toggleNavigation(index) {
// First we close any open disclosures not related to the
//current button in focus by looping over all nav buttons
navButtons.forEach((button, buttonIndex) => {
if (buttonIndex != index) {
closeNavigation(button);
}
});
const currentButton = event.target;
const open = currentButton.getAttribute("aria-expanded");
open === "false"
? openNavigation(currentButton)
: closeNavigation(currentButton);
}
// Adds the toggle event to every top level button
navButtons.forEach((button, index) => {
button.addEventListener("click", () => toggleNavigation(index));
});
Closing the disclosure with Escape
If the disclosure is open and a user presses the Escape key, we want the disclosure to disappear but, more importantly, we need to manage focus so that the user gets a good keyboard experience.
To do this we add an event listener for the Escape key, find the button which is connected to the open disclosure and use the .focus()
method to send focus back to the button that originally opened the disclosure.
// This adds a global event listener to close any open disclosures when the escape key is pressed
window.addEventListener("keyup", (e) => {
if (e.key === "Escape") {
const navButtonsArr = Array.from(navButtons);
const currentOpenButtonIndex = navButtonsArr.findIndex(
(button) => button.getAttribute("aria-expanded") === "true"
);
// If there is an open disclosure, close it and send focus back to the button that controls it.
if (currentOpenButtonIndex >= 0) {
const currentOpenButton = navButtons[currentOpenButtonIndex];
currentOpenButton.focus();
closeNavigation(currentOpenButton);
}
}
});
Closing when the user tabs away
If a user tabs away then ideally the disclosure closes so that they can see any content underneath. You might first think that an onblur
event on the last element will work for this. However, the onblur
event gets triggered if you are tabbing backwards as well as forwards, which would prevent users from ever tabbing backwards from the last element in the disclosure. Instead, we'll check if the item in focus is within the disclosure. If not, we'll close it.
// This function closes an open disclosure if a user tabs away from the last anchor element in the list. It is reliant on the top-level list item of the top level ul having a class to find the group containing button + disclosure it controls
function handleBlur(button) {
const navList = event.currentTarget.closest(".nav-group");
if (!event.relatedTarget || !navList.contains(event.relatedTarget)) {
closeNavigation(button);
}
}
// This adds the handleBlur event to the last anchor element in each disclosure
disclosures.forEach((disclosure) => {
const listItems = disclosure.querySelectorAll("li a");
listItems[listItems.length - 1].addEventListener("blur", (event) => {
handleBlur(disclosure.previousElementSibling);
});
});
Final thoughts
It may seem like a lot of code for such a "simple" pattern and this is not even including the CSS to style it! It's probably not as much code when using something like React, so don't be scared away. The important thing is remembering the different fuctionality that needs to be added.
It's also important that we get used to adding in the code to make our components work for everyone, irrespective of the device they're using to access the content. And once you've done it a few times it will become second nature to add all the code needed to make every component accessible.
Top comments (2)
Shouldn't they have the role of menubar / menu / menuitem?
No, they are not menus but the word menu is often misused to describe them. The menu roles bring a lot of complexity regarding their expected behaviour which is rarely needed on the web.
Adrian Roselli has written an in-depth blog about why you should avoid menu roles adrianroselli.com/2017/10/dont-use...