Last week, I wrote about Dave Rupert's Nutrition Cards for Accessible Components. I said that I'd be building each one from scratch. The first component is an accordion, so let's get started!

Progressive enhancement

We should build our accordion with progressive enhancement in mind. We'll use a set of semantic headings and paragraphs in our HTML; this is still a good and accessible experience without JavaScript. We'll use JavaScript to enhance the experience and create the accordion. Here's the HTML we'll use:

<div data-accordion="">
<article>
<h2>
Prow scuttle parrel provost
</h2>
<p>
Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl. Swab barque interloper chantey doubloon starboard grog black jack gangway rutters.
</p>
</article>

<article>
<h2>
Deadlights jack lad schooner
</h2>
<p>
Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors. Bring a spring upon her cable holystone blow the man down spanker Shiver me timbers to go on account lookout wherry doubloon chase. Belay yo-ho-ho keelhaul squiffy black spot yardarm spyglass sheet transom heave to.
</p>
</article>

<article>
<h2>
Trysail Sail ho Corsair
</h2>
<p>
Trysail Sail ho Corsair red ensign hulk smartly boom jib rum gangway. Case shot Shiver me timbers gangplank crack Jennys tea cup ballast Blimey lee snow crow's nest rutters. Fluke jib scourge of the seven seas boatswain schooner gaff booty Jack Tar transom spirits.
</p>
</article>
</div>

We've wrapped the accordion in a div element with a data-accordion attribute; this is what we'll use to select the accordion in our script. Each of the 'panels' is wrapped in an article element.

Creating the panels

From our script, the first thing we need to do is convert each section of the accordion into a panel. Let's build an accordion in which a panel must always be open, and only one panel may be open at a time.

First of all, let's select the accordion and its children:

// Get the accordion and its children
const accordion = document.querySelector('[data-accordion]');
const sections = [ ...accordion.children ];

The Element.children property returns all the article elements as an HTMLCollection. We'll need to use some array methods on it, so we use the spread syntax to convert it to a true array.

Now let's create a function, createPanel(), that we'll call once for each section. We'll use this as our callback function for the forEach() method. The function has two parameters:

  1. section, which is an article element, and an instance of the HTMLElement interface
  2. index, which is the index of section in the sections array, and of type number
/**
* Convert a section into an accordion panel.
* @param {HTMLElement} section
* @param {number} index
*/

function createPanel(section, index) {
// ...
}

// Add the necessary elements and attributes
sections.forEach(createPanel);

The first thing we do is use destructuring assignment to extract the heading and paragraph elements from the Element.children property. We assign these to the heading and content constants:

// Get the heading and content for this panel
const [ heading, content ] = section.children;

The nutrition card says Accordion header buttons have aria-controls set to the ID of the associated accordion panel content. We know we'll need to reference this ID in two places, so we create a constant for it. The ID has to be unique, so we append it with the index. This means it'll be different for each panel:

// The ID for the aria-controls attribute
const id = `content-${index}`;

We only want to show the first panel by default. If the index is 0, that means it's the first panel, and the value will be true. It will be false otherwise. We can create a constant for this boolean value:

// Whether the content should be visible by default
const isFirstPanel = index === 0;

If this isn't clear, you can put parentheses around the index === 0 part.

Next, we set some attributes. We use the dataset property to set a data-panel attribute on the article element, which we'll use as a selector. We use the id property to set the ID attribute of the content to the value of the id constant we created earlier:

// Set the necessary attributes
section.dataset.panel = '';
content.id = id;

Now we use our isFirstPanel boolean to hide all but the first panel. If this isn't the first panel, we set the value of its hidden property to true, which sets the HTML hidden attribute and hides the element:

// If this isn't the first panel, hide its content
if (!isFirstPanel) {
content.hidden = true;
}

Finally, we set the innerHTML property to replace the heading text with a button element. The nutrition card says Each accordion header title is contained in an element with role button (e.g. <button>).

// Replace the heading text with a button
heading.innerHTML = `
<button
type="button"
data-heading=""
aria-controls="
${id}"
aria-expanded="
${isFirstPanel}"
aria-disabled="
${isFirstPanel}"
>
${heading.textContent}
</button>
`
;
  • We'll be using JavaScript to create the functionality for this button, so we set its type attribute to the value of button. This means it does nothing by default, and its behaviour will be controlled via a script.
  • We add a data-heading attribute to be used as a selector.
  • The aria-controls attribute references the ID we created earlier for the content.
  • We add the aria-expanded attribute and set its value to true or false, as per the nutrition card: If the accordion panel is visible, the header button element should have aria-expanded set to true. If the panel is not visible, aria-expanded is set to false.
  • The nutrition card doesn't mention this, but we also handle the aria-disabled attribute in the same way. We need to do this because when a panel is already open, we're not permitting it to be collapsed. The WAI-ARIA Authoring Practices mention this: If the accordion panel associated with an accordion header is visible, and if the accordion does not permit the panel to be collapsed, the header button element has aria-disabled set to true.
  • We use the textContent property to set the button's descriptive label to the original heading text.

Showing a panel

Before we can handle our mouse and keyboard events, we need to create functions for showing and hiding a panel's contents. Let's start with our show() function. It has a single parameter, panel, which refers to an HTML article element, and is therefore an instance of the HTMLElement interface:

/**
* Show an accordion panel.
* @param {HTMLElement} panel
*/

function show(panel) {
// ...
}

The first thing we need to do is get the content and button elements from the panel we pass in. We again use destructuring assignment to achieve this, assigning constants for heading, content, and button:

// Get the necessary elements
const [ heading, content ] = panel.children;
const [ button ] = heading.children;

Next, we need to check if the panel is already open. We do this by calling the getAttribute() method on the button element, getting the value of its aria-expanded attribute. If the value is 'true', we use an empty return statement to stop the function execution:

// If the panel is already open, do nothing
const isOpen = button.getAttribute('aria-expanded');
if (isOpen === 'true') return;

Assuming the panel was closed, the function keeps executing. We use the setAttribute() method to set the values of the aria-expanded and aria-disabled attributes to 'true'.

// Mark the panel as expanded and disabled
button.setAttribute('aria-expanded', 'true');
button.setAttribute('aria-disabled', 'true');

Finally, we show the content by setting the value of its hidden property to false. This removes the hidden attribute from the element in the HTML, making it visible:

// Show the content
content.hidden = false;

Hiding a panel

We need a hide() function too. It's just the reverse of the show() function, so I'll give you the function in its entirety without all the explanations:

/**
* Hide an accordion panel.
* @param {HTMLElement} panel
*/

function hide(panel) {
// Get the necessary elements
const [ heading, content ] = panel.children;
const [ button ] = heading.children;

// Mark the panel as collapsed and enabled
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-disabled', 'false');

// Hide the content
content.hidden = true;
}

Handling click events

With our show() and hide() functions in place, we can write our click event handler. We'll create a function, handleClick(), which will accept a single argument, event, for the Event interface. We'll use this as our callback function for the addEventListener() method:

/**
* Handle click events.
* @param {Event} event
*/

function handleClick(event) {
// ...
}

// Handle click events
accordion.addEventListener('click', handleClick);

Because we're dealing with elements that have been dynamically added to the page, we're going to use a technique called event delegation. This means we don't need to add separate handlers for every button element.

We need to make sure the element that fired the event was one of the buttons we inserted; we can do this using the Element.matches() method. If not, we use an empty return statement to cease the function execution:

// If this wasn't a heading, do nothing
if (!event.target.matches('[data-heading]')) return;

Although we've filtered clicks down to the button elements, we need to get the closest article element, since that's what we need to pass into the show() function. We can do this using the Element.closest() method:

// Get the panel that was activated
const thisPanel = event.target.closest('[data-panel]');

We also need to get the panels that weren't activated, so that we can hide them. We can do this using the filter() method on the sections array, filtering out the elements that aren't the one we just selected:

// Get the other panels
const otherPanels = sections.filter(section => section !== thisPanel);

Finally, we can show the panel that was activated, and hide the others:

// Show this panel
show(thisPanel);

// Hide the other panels
otherPanels.forEach(hide);

Handling keydown events

Finally, we can write our handler for keydown events. Because we've used native button elements, the following behaviours are already taken care of:

  • Enter or Space = Expands/Collapses Panel
  • Tab = Move to next focusable element
  • Shift + Tab = Move to previous focusable element

All we need to worry about is the up/down arrow keys: = Cycle headers when header focused.

Let's create our handleKeydown() function, which will also accept a single argument for the Event interface. We'll again use this as a callback function for the addEventListener() method:

/**
* Handle keydown events.
* @param {Event} event
*/

function handleKeydown(event) {
// If we already handled the event, do nothing
if (event.defaultPrevented) return;

// The rest of the function will go here...

// Prevent the default action to avoid handling the event twice
event.preventDefault();
}

// Handle keydown events
document.addEventListener('keydown', handleKeydown);

You'll notice we already have some code which uses the defaultPrevented property and the preventDefault() method. The up/down arrow keys are normally used for scrolling, so this prevents the page from scrolling while we're using the arrow keys to navigate between the accordion headers, which could be a bit jarring for our users.

The nutrition card says All keyboard interactions relate to when headers are focused, so we need to check for that. We can do this by using the Element.closest() method on the document.activeElement property. If a header is not in focus, we use an empty return statement to cease the function execution:

// If there is no heading in focus, do nothing
const isHeading = document.activeElement.matches('[data-heading]');
if (!isHeading) return;

We only want to run our keydown handler if the up/down arrow key is pressed, so we'll check for that too. We can do this by checking the value of the event.key property:

// If it wasn't the up/down arrow key, do nothing
const isArrowUp = event.key === 'ArrowUp' || event.key === 'Up';
const isArrowDown = event.key === 'ArrowDown' || event.key === 'Down';
if (!isArrowUp && !isArrowDown) return;

The string values 'ArrowUp' and 'ArrowDown' refer to the up/down arrow keys. Internet Explorer, Edge (16 and earlier), and Firefox (36 and earlier) use 'Up' and 'Down' instead, so we check for those values too. If the user presses anything other than the up/down arrow keys, we use an empty return statement to cease the function execution.

Assuming it was one of the arrow keys, we continue. The first thing we do is get an array of all of the accordion headers, using the Element.querySelectorAll() method on the accordion element, and the spread syntax:

// Get all of the headings
const headings = [ ...accordion.querySelectorAll('[data-heading]') ];

Now that we have our array, we need to find the index of the accordion header that's currently in focus. We can achieve this using the findIndex() method:

// Find the index of the heading that's in focus
const index = headings.findIndex(heading => {
return heading === document.activeElement;
});

Finally, we need to shift focus to the previous/next accordion header, depending on whether the up/down arrow key was pressed. We can do this by adding/subtracting 1 to/from the index, and calling the HTMLElement.focus() method on the button at that index:

// Shift focus to the next heading
if (isArrowUp) {
headings[index - 1]?.focus();
} else if (isArrowDown) {
headings[index + 1]?.focus();
}

We use the optional chaining operator (?.) to avoid errors when reaching the first and last accordion headers. Without it, we'd get a TypeError:

Uncaught TypeError: Cannot read properties of undefined (reading 'focus').

This is because, if the first accordion header is already in focus, the value of the index is 0. Then we try to call the focus() method on the header at index -1, which is undefined, and there is no focus() method on undefined.

The same is true when the last header is in focus. We have three panels in our accordion, meaning the index of the last header is 2. There is no header at index 3, so we get the same error.

Adding some styles

Let's add some styles to make the accordion look a bit nicer.

Removing the default button styles

Firstly, we'll get rid of the default 'button' look on our accordion headers. It's critical that we do not remove the focus styles, so we will not modify the outline property. Otherwise, we'd be missing one of the key points on the nutrition card: Headers should have visible keyboard focus state. We could customise the focus outline if we wanted to, as long as it was still clearly perceivable, but let's just stick with the default.

[data-heading] {
padding: 0;
border: 0;
font: inherit;
color: inherit;
background: none;
cursor: pointer;
}

Adding disclosure triangles

We want the accordion headers to have little disclosure triangles, which we can achieve using the ::before pseudo-element and the content property:

[data-heading][aria-expanded='false']::before {
content: '▸ ';
}

[data-heading][aria-expanded='true']::before {
content: '▾ ';
}

Marking disabled buttons

Finally, when an accordion header is disabled, we want this to be visually obvious. This is specifically mentioned in the WAI-ARIA guide: In addition to setting the aria-disabled attribute, authors should change the appearance (grayed out, etc.) to indicate that the item has been disabled. We'll reduce the opacity a bit to make it look 'grayed out', and we'll change the cursor.

We're using the opacity property instead of setting an explicit colour, because this will work across light and dark mode. I have checked that the resulting colours still meet WCAG AA compliance for colour contrast.

[data-heading][aria-disabled='true'] {
opacity: 0.6;
cursor: not-allowed;
}

Demo

I hope you've found this tutorial informative! You can view the demo and check the source code on GitHub. Stay tuned for next week, when we'll look at an accessible button component.