Last week, we looked at an accessible button component based on Dave Rupert's Nutrition Cards for Accessible Components. This week, we're going to look at an accessible disclosure (show/hide) component.

The HTML

We need to build our component with progressive enhancement in mind. Without JavaScript, we won't bother with the show/hide functionality at all. We'll just make the content visible by default:

<p id="content">
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>

We'll use JavaScript to enhance the experience and create our disclosure widget. But without it, our content will still be accessible to everyone.

The CSS

Most of the styles are just to make the button look nicer, but there is something worth mentioning from an accessibility standpoint. The WAI-ARIA Authoring Practices say it's common to add an arrow/triangle to the button that controls the content:

โ€œWhen the controlled content is hidden, the button is often styled as a typical push button with a right-pointing arrow or triangle to hint that activating the button will display additional content. When the content is visible, the arrow or triangle typically points down.โ€

It's good to stick with people's expectations of what a component should look like, so we'll honour this.

When I first approached this, I added some HTML entities using the ::before pseudo-element and the content property in my CSS. The trouble with this approach is that some assistive technologies announce the arrow/triangle as content. It's only meant to be decorative! When I tested VoiceOver in Safari, the announcement for the 'Show' button was 'Right-pointing small triangle Show'. The extra content pollutes the label; it should just say 'Show'.

How do we fix this? We can't use the aria-hidden attribute because the ::before pseudo-element doesn't actually exist in our HTML. But we can draw the triangles using CSS!

[aria-expanded] {
display: flex;
align-items: center;
}

[aria-expanded]::before {
content: '';
width: 0.5em;
height: 0.5em;
margin-right: 0.25em;
background: currentColor;
}

[aria-expanded='false']::before {
clip-path: polygon(0 0, 100% 50%, 0 100%);
}

[aria-expanded='true']::before {
clip-path: polygon(0 0, 100% 0, 50% 100%);
}

All we're doing here is inserting a small square. The clip-path property does all the heavy lifting, making the visible part of the pseudo-element appear triangular. Because this is purely visual, and not actual content, screen readers shouldn't announce it.

The JavaScript

Now that we have our HTML and CSS in place, we can enhance the HTML into a disclosure widget.

Get the content and create the button

First of all, we'll get the content and create the button. The querySelector(), createElement(), and setAttribute() methods are useful here:

// The ID for the content
const contentId = 'content';

// Create the trigger
const trigger = createTrigger();

// Get the content
const content = document.querySelector(`#${contentId}`);

/**
* Create the trigger for the content.
* @returns {HTMLButtonElement}
*/

function createTrigger() {
// Create a button element
const button = document.createElement('button');
button.textContent = 'Show';

// Set the necessary attributes
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', contentId);

// Return the button element
return button;
}

Because we need to reference the 'content' string in two places, we save it to a constant. This helps to avoid bugs, because it's critical that the ID of the content matches up with the aria-controls attribute on the button.

The reason we're seemingly able to call the createTrigger() function before it's declared is because function declarations are hoisted. I don't often do this, but I prefer to keep my variables at the top of my script, so I make an exception here. There's nothing wrong with hoisting, though. It's just my personal preference.

Handling click events

With our button in place, we can write the logic for our click event listener. We'll get the current value of its aria-expanded attribute ('true' or 'false') and show/hide the content based on that. We can do this using the getAttribute() method and an if statement:

/**
* Handle click events.
*/

function handleClick() {
// Get the expanded state
const isExpanded = trigger.getAttribute('aria-expanded');

// Show/hide the content based on the state
if (isExpanded === 'true') {
hide();
} else if (isExpanded === 'false') {
show();
}
}

We haven't declared the show() and hide() functions yet; we'll do that next.

Showing and hiding the content

To show and hide the content, we'll manipulate its hidden property. We'll also update the button based on its accessible labelling expectations.

/**
* Show the content.
*/

function show() {
// Show the content
content.hidden = false;

// Update the trigger
trigger.textContent = 'Hide';
trigger.setAttribute('aria-expanded', 'true');
}

/**
* Hide the content.
*/

function hide() {
// Hide the content
content.hidden = true;

// Update the trigger
trigger.textContent = 'Show';
trigger.setAttribute('aria-expanded', 'false');
}

Initialising the script

Finally, to initialise the script, we need to:

  • Hide the content
  • Insert the button
  • Set up the event listener

We'll create a function, init(), which we'll call from the bottom of our script. We'll use the before() method to insert the button before the content, and the addEventListener() method to configure our event listener:

/**
* Initialise the script.
*/

function init() {
// Hide the content
content.hidden = true;

// Insert the trigger
content.before(trigger);
trigger.addEventListener('click', handleClick);
}

// Initialise the script
init();

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 'menu and menu button' component.