Originally Posted on www.a11ywithlindsey.com.
Content Warning: There are gifs in this post.
Hey friends! Today's writing is going to be a follow up to How Accessibility Taught Me to be Better At JavaScript. If you have read my content, one of my favorite topics is about JavaScript and Accessibility. I speak about how JavaScript is actually necessary to make interactive elements accessible.
In my previous article, I talked about how I created a popup language menu with accessibility in mind. Making something functional and accessible was my first taste of vanilla JavaScript. The code certainly needed improvements, which we went over in the post. However, making the menu accessible started to help me understand JavaScript better.
Today we are going over how I took some cringy “accordion” markup and made it accessible. Remember, an essential requirement was that I was not allowed to alter the content markup in any way. This page was a WordPress post, meaning I couldn’t go in and edit the post to be the markup I wanted it to be.
Starting out
So, this right here was the starting markup.
I like clean HTML, and the inability to alter the markup got under my skin. This markup is a mess. First, it started with an unordered list, which isn’t the worst, but not ideal. Then inside the list item, it has a span for the title of the panel, an h3, another unordered list element, then a singular list item (meaning it’s not even a list?).
I detest this markup so much.
Now that I finished that soapbox, let's talk about a few goals here:
- Hide the panels when we load the page
- Accordion panels open and close on click.
- Accordion panels open and close using the space bar or the enter key.
- Make that span focusable
I added a little bit of SCSS to clean up the markup. I also added normalize.css in my CodePen settings.
Now let's get on to how I approached this problem 4 years ago.
How I approached the problem
As a disclaimer, this is what Lindsey 4 years ago did. There's only one thing I wouldn't do; however, even so, I would add more to this code, which I do in the next section.
First, let's grab some variables:
const accordion = document.getElementById('accordion')
Then, let's make a conditional statement. If that accordion exists, let's grab some other variables.
if (accordion) {
const headers = document.querySelectorAll('.accordion__header')
const panels = document.querySelectorAll('.accordion__panel')
}
I added the conditional statement because we loop through that nodeList. I don’t want to be adding event listeners on null
Now let's add the event listener
if (accordion) {
const headers = document.querySelectorAll('.accordion__header')
headers.forEach(header => header.addEventListener('click', toggleAccordion))
const panels = document.querySelectorAll('.accordion__panel')
}
Then, let's add that function where the .accordion__header
represents this
and the .nextElementSibling
is the .accordion__panel
function toggleAccordion() {
this.nextElementSibling.classList.toggle('visually-hidden')
}
If we go to the element inspector and click on the accordion item, we notice the class toggle.
Then let's add the visually-hidden
class in the SCSS (source: The A11y Project):
.visually-hidden {
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
}
Now let's add the visually-hidden
class to the panels, so it visually toggles.
if (accordion) {
const headers = document.querySelectorAll('.accordion__header')
headers.forEach(header => header.addEventListener('click', toggleAccordion))
const panels = document.querySelectorAll('.accordion__panel')
panels.forEach(panel => panel.classList.add('visually-hidden'))
}
If you’re not thinking about accessibility, you may only add a click event and call it a day. Because these are not buttons, we have to add keypress events. We need to replicate the functionality of a button. This reason is why using semantic HTML is the best way to help accessibility.
First, we have to add a tabindex of 0 to every header.
if (accordion) {
const headers = document.querySelectorAll('.accordion__header')
headers.forEach(header => {
header.tabIndex = 0
header.addEventListener('click', toggleAccordion)
})
const panels = document.querySelectorAll('.accordion__panel')
panels.forEach(panel => panel.classList.add('visually-hidden'))
}
When we do that, we can see the focus styles whenever we press the tab
key.
If we press the enter or space key, nothing happens. That's because this isn't a button
element with built-in keyboard events on click. That's why I preach from time to time about using semantic HTML.
We have to add a keypress
event on the header elements.
headers.forEach(header => {
header.tabIndex = 0
header.addEventListener('click', toggleAccordion)
header.addEventListener('keypress', toggleAccordion)
})
This “works” but not quite how we want it. Because we haven't separated which key we want to toggle the class on, it wouldn't matter if we hit the k
key or the Space bar.
So first, let's pass the event into the toggleAccordion
function and console.log()
that
function toggleAccordion(e) {
console.log(e)
this.nextElementSibling.classList.toggle('visually-hidden')
}
Quick interruption here. Even though I prefer buttons for this, learning how to do it the wrong way taught me a LOT about JavaScript. I learned about event handlers and the event object. As someone who was a newb to JavaScript, I learned a great deal from exploring, even if this wasn't the best way to write the code.
Back to talking about events. When we open this up in the console, we see a bunch of properties on that event.
I see a few things I can use, particularly the code
or key
. I'm going to use the key
property because it's a bit more verbose when I press the space bar.
So I can do this, right?
function toggleAccordion(e) {
if (e.code === 'Enter' || e.code === 'Space') {
this.nextElementSibling.classList.toggle('visually-hidden')
}
}
Well, no. Because this doesn't account for the click
event. Click events don't have the code
property. What types of properties do they have that we can use to make this work for this click event? Let's add the console.log(e)
back into our function and see what we have available to us.
So now, I check if the type
is click or code
is a space or enter.
To make this a bit easier to read, I am going to separate the code
into a ternary operator that returns true or false. I didn't do that when I was initially doing this, but I wanted to add a little bit of readability into my conditional.
function toggleAccordion(e) {
const pressButtonKeyCode =
e.code === 'Enter' || e.code === 'Space' ? true : false
if (e.type === 'click' || pressButtonKeyCode) {
this.nextElementSibling.classList.toggle('visually-hidden')
}
}
And now we can click AND open with a space bar and enter key.
There are a ton of things I would improve upon, which we'll go over next. But if you want to take a look at the code, take a look at the CodePen below:
What I would change now
While this technically works, it's not the most ideal. I had no idea what progressive enhancement was when I was learning JavaScript. I also had no idea what ARIA was.
So let's start walking through it. If you read part 1, you’ll know that I am a big fan of having a no-js
class as a way to detect if JavaScript has loaded or not.
<ul id="accordion" class="accordion no-js">
<!-- Children elements -->
</ul>
Then the first thing we do when our JavaScript loaded is removing that class.
const accordion = document.getElementById('accordion')
accordion.classList.remove('no-js')
We'll add some default styling if the no-js
class is present, meaning that JavaScript wouldn't have loaded:
.accordion {
&.no-js {
.accordion__header {
display: none;
}
.accordion__item {
border-top: 0;
border-bottom: 0;
&:first-child {
border-top: 1px solid;
}
&:last-child {
border-bottom: 1px solid;
}
}
.accordion__panel {
display: block;
border-top: 0;
}
}
}
I've removed the button that isn't technically a button and had everything open by default.
Now, back into the JavaScript. On the headers, we want to set the aria-expanded
attribute to false and give it a role of button.
headers.forEach(header => {
header.tabIndex = 0
header.setAttribute('role', 'button')
header.setAttribute('aria-expanded', false)
header.addEventListener('click', toggleAccordion)
header.addEventListener('keypress', toggleAccordion)
})
While we are setting roles, I am going to set the panels' role to region
if (accordion) {
// header code
panels.forEach(panel => {
panel.setAttribute('role', 'region')
}
}
Next, I'll toggle aria-expanded and remove the toggling of the class in the function. As a note, even though we set the attribute to be a boolean, getAttribute()
returns a string.
function toggleAccordion(e) {
const pressButtonKeyCode =
e.code === 'Enter' || e.code === 'Space' ? true : false
const ariaExpanded = this.getAttribute('aria-expanded')
if (e.type === 'click' || pressButtonKeyCode) {
if (ariaExpanded === 'false') {
this.setAttribute('aria-expanded', true)
} else {
this.setAttribute('aria-expanded', false)
}
}
}
We don’t need to visually hide the content because we have the button that controls the information. It's not a good screen reader user experience to read the information they didn't want. I love using aria-expanded
in CSS to toggle between display: none
and display: block
for the panel.
.accordion {
&__header {
// more scss
&[aria-expanded='true'] + .accordion__panel {
display: block;
}
}
&__panel {
display: none;
padding: 1rem;
border-top: 1px solid;
h3 {
margin-top: 0;
}
}
}
I'm going to be adding a few ARIA attribute to help associate the header and the panel together.
- aria-controls - This can be confusing for some people. I highly recommend reading Léonie's post
- aria-labelledby
I based this on the WAI-ARIA authoring practices.
First, the headers:
headers.forEach(header => {
header.tabIndex = 0
header.setAttribute('role', 'button')
// This will match the aria-labelledby on the panel
header.setAttribute('id', `accordion-header-${i + 1}`)
header.setAttribute('aria-expanded', false)
// This will match the id on the panel
header.setAttribute('aria-controls', `accordion-section-${i + 1}`)
header.addEventListener('click', toggleAccordion)
header.addEventListener('keypress', toggleAccordion)
})
Then we'll take those and make sure they match up accurately with the panels
panels.forEach(panel => {
// This will match the aria-controls on the header
panel.setAttribute('id', `accordion-section-${i+1}`)
panel.setAttribute('role', 'region')
// This will match the id on the header
panel.setAttribute('aria-labelledby', `accordion-header-${i+1}`)
}
If you want to play around with the code, fork the CodePen and check it out.
Conclusion
Was this the most ideal markup ever? No. Did this teach me a lot about JavaScript? Yes. Did this teach me the value of using buttons where I have keyboard events built-in? Yes.
Stay in touch! If you liked this article:
- Let me know on Twitter and share this article with your friends! Also, feel free to tweet me any follow up questions or thoughts.
- Support me on patreon! If you like my work, consider making a $1 monthly pledge. You’ll be able to vote on future blog posts if you make a \$5 pledge or higher! I also do a monthly Ask Me Anything Session for all Patrons!
- Take the 10 days of a11y Challenge for more accessibility funsies!
Cheers! Have a great week!