Building an accessible accordion component with Vue (Part 1)
February 22, 2024
Accordions are a common UI pattern used to manage content in a collapsible and expandable manner. They are especially useful when dealing with a large amount of information that needs to be organized and presented in a structured way. However, when implementing accordions, it's crucial to ensure accessibility for all users, including those who rely on assistive technologies like screen readers or keyboard navigation. In this blog post, we'll walk through the process of creating an accessible-friendly accordion component using Vue 3, along with writing unit tests to verify its functionality in Part 2.
Gather Requirements
Before we begin, it's essential to gather some information about the accessibility (a11y) requirements for an Accordion component. Fortunately, w3.org provides valuable insights for us. After reviewing the Accordion Pattern guidelines, we can summarize the following key points:
Accordions have two primary sections
- Accordion Header: Label for or thumbnail representing a section of content that also serves as a control for showing, and in some implementations, hiding the section of content.
- Accordion Panel: Section of content associated with an accordion header.
A11y Requirements:
Looking at the keyboard interactions required we will need to support the following movements:
Enter
orSpace
will expand the content (panel) could also close the contentTab
Moves focus between HeadersDown Arrow
(optional) - Moves focus to the next headerUp Arrow
(optional) - Moves focus to the prev headerHome
(optional) - Moves focus to the first headerEnd
(optional) - Moves focus to the last header
We will also need to support the following Aria Properties:
aria-expanded
- toggle true/false depending on the state of the panel (open/closed)aria-labelledby
- set on the panel to refer to the button that controls display of the panelrole=region
- set on the panel to show panel as a container
At this point, I would like to bring attention to the fact that W3 doesn't recommend using aria-hidden
. This is somewhat dependent on the implementation of the component and how we are showing/hiding the panels. The following quote is from MDN, and describes when not to use aria-hidden
.
aria-hidden="true" should not be added when:
- The HTML hidden attribute is present
- The element or the element's ancestor is hidden with display: none
- The element or the element's ancestor is hidden with visibility: hidden
You can also view the example directly from the W3C and notice the use of the hidden
attribute over aria-hidden
when using CSS (display:none;
) directly to control the visibility.
The Component
When building a component, I usually prefer to begin by defining the API that developers would utilize to configure the component. This practice enables me to identify any potential shortcomings early in the development process. For instance, in our accordion component, the presence of multiple sections is essential for accommodating various options. While one approach could entail creating two distinct components, such as AccordionGroup and Accordion, promoting composition over props, it inevitably increases the required understanding and documentation regarding the relationship between the components. However, with Vue and named slots, we can achieve the same objective of composition over props, effectively leveraging both strategies concurrently.
Define Props and Refs
We will be using the Vue 3 Composition API via the <script setup>
tag for the following code.
/**
* Structure of our section object.
*
* @typedef {Object} SectionProps
* @property {string} slug
* @property {string} heading
* @property {string} content
*/
const props = defineProps({
sections: { type: Array, required: true },
headingAs: { type: String, default: 'h3' },
initialOpen: { type: String },
});
const accordionGroupEl = ref(null)
const buttonArray = ref([])
const currentFocusedIndex = ref(0)
const currentExpandedIndex = ref(-1)
So far we have defined the props our component will use. Lets go over these a bit:
- sections - An
array
ofobjects
which define our accordion sections. - headingAs - An optional prop that defines the html element used for our heading.
- initialOpen - An optional prop that open a panel on initial rendering.
Next lets talk about the reactive properties we defined as these will help us maintain the state of our accordion:
- accordionGroupEl - This is a
ref
link to the rootel
of our component. - buttonArray - This will end up being a
NodeList
of our buttons. - currentFocusedIndex - Keep track of focus
- currentExpandedIndex - Keep track of what panel is open
Markup
<div
class="accordion-group"
ref="accordionGroupEl"
data-test-id="accordion-group"
>
<div
class="accordion"
v-for="(section, index) in sections"
:key="section.slug"
>
<component
class="accordion-heading"
:is="headingAs"
>
<button
class="accordion-button"
type="button"
:id="`${section.slug}-heading`"
>
<slot :name="`${section.slug}-heading`">
{{ section.heading }}
</slot>
</button>
</component>
<div
class="accordion-panel"
:id="section.slug"
>
<slot :name="`${section.slug}-content`">
{{ section.content }}
</slot>
</div>
</div>
</div>
The markup is pretty straightforward. We are building the accordion with a parent wrapper accordion-group
. We are assigning this a ref
so we can access this root level node. We also give this a data-test-id
that we will use in our unit tests in Part 2 (Coming Soon).
Next is the use of the component
prop. We are using this here so we can change the element for our heading
. By default, this will be an h3
, but to maintain semantic markup, we might need to use an h2
, h4
, etc.
In the button and accordion-panel elements, you'll notice we use named slots with the provided slug. This helps us assemble our component in a flexible way, adhering to the composition over props concept. I appreciate this approach because it offers us options. For instance, if we have simple text sections, we can set them up quickly by just using an array of objects. However, if we need more complicated layouts for each section, we can now utilize these slots. We simply use {slug}-heading
or {slug}-content
depending on our requirements.
Component Logic
Let's start writing some of our logic to handle the interactivity of our component. We'll begin by setting up the onMounted
hook.
onMounted(() => {
buttonArray.value = accordionGroupEl.value?.querySelectorAll('.accordion-button')
if(props.initialOpen !== undefined) {
const index = props.sections.findIndex(section => section.slug === props.initialOpen)
if(index !== -1) {
toggleSelectedPanel(index)
}
}
});
In the code snippet above, we're initializing our buttonArray
reference to contain all the elements with the class accordion-button
. Additionally, we're checking whether the initialOpen
prop is specified. If it is, we search for the index of the provided slug within our sections. If we find the index, we trigger the toggleSelectedPanel
method. We'll delve into setting up this method shortly.
const handleFocusIn = (event) => {
currentFocusedIndex.value = Array.prototype.indexOf.call(buttonArray.value, event.target);
}
const toggleSelectedPanel = (index) => {
currentExpandedIndex.value = currentExpandedIndex.value === index ? -1 : index;
}
Here, we're introducing two essential functions to manage the behavior of our accordion component. The handleFocusIn
function is responsible for tracking the currently focused button within the component, which we'll invoke using the @focus
event handler. Following that, the toggleSelectedPanel
method facilitates the opening and closing of panels accordingly. If the currentExpandedIndex
is already set to the provided index, we'll close the panel by setting currentExpandedIndex
to -1. Conversely, if the index differs, we'll expand the panel by updating currentExpandedIndex
to the provided index. These functions are pivotal for ensuring smooth interaction and proper functionality within our accordion component.
Next, we'll add some computed properties that will help us keep track of the previous and next indexes. We use computed
here so that prevIndex
and nextIndex
are reactive to when we set currentFocusedIndex
from the handleFocusIn
method.
const nextIndex = computed(() => {
return currentFocusedIndex.value === buttonArray.value.length - 1
? 0
: currentFocusedIndex.value + 1
});
const prevIndex = computed(() => {
return currentFocusedIndex.value === 0
? buttonArray.value.length - 1
: currentFocusedIndex.value - 1
});
All together now, we should have a component where our <script setup>
looks like this:
<template>
<!-- ... component markup -->
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
sections: { type: Array, required: true },
headingAs: { type: String, default: 'h3' },
initialOpen: { type: String },
})
const accordionGroupEl = ref(null)
const buttonArray = ref([]) // Nodelist
const currentFocusedIndex = ref(0)
const currentExpandedIndex = ref(-1)
const nextIndex = computed(() => {
return currentFocusedIndex.value === buttonArray.value.length - 1
? 0
: currentFocusedIndex.value + 1
});
const prevIndex = computed(() => {
return currentFocusedIndex.value === 0
? buttonArray.value.length - 1
: currentFocusedIndex.value - 1
});
const handleFocusIn = (event) => {
currentFocusedIndex.value = Array.prototype.indexOf.call(buttonArray.value, event.target);
}
const toggleSelectedPanel = (index) => {
currentExpandedIndex.value = currentExpandedIndex.value === index ? -1 : index;
}
onMounted(() => {
buttonArray.value = accordionGroupEl.value?.querySelectorAll('.accordion-button')
if(props.initialOpen !== undefined) {
const index = props.sections.findIndex(section => section.slug === props.initialOpen)
if(index !== -1) {
toggleSelectedPanel(index)
}
}
});
</script>
Now that we have some of the logic worked out, let's update our markup to handle events and visibility of the panels. First, let's add the @focus
and @pointerup
events to the button
element. Notice that we are using pointerup
here to handle both click and touch events; you'll need to take note of this when we get to writing our unit tests in Part 2. For more information on pointerup
, see the MDN docs here.
<button
class="accordion-button"
type="button"
:id="`${section.slug}-heading`"
@focus="handleFocusIn"
@pointerup="toggleSelectedPanel(index)"
>
<slot :name="`${section.slug}-heading`">
{{ section.heading }}
</slot>
</button>
Next lets update our accordion-panel
to use v-show:
<div
class="accordion-panel"
:id="section.slug"
v-show="currentExpandedIndex === index"
>
<slot :name="`${section.slug}-content`">
{{ section.content }}
</slot>
</div>
By now, your accordion should be working, tracking the focus when tabbing into it, along with showing/hiding the panels when clicking on the buttons.
Keyboard Events
So back in the a11y requirements, we noted which keyboard events we will need to support and what should happen upon triggering these events. Let's write a method to handle these events:
const handleKeyDown = (event) => {
if (currentFocusedIndex.value === -1) return;
const { code } = event;
switch (code) {
case 'Home':
event.preventDefault();
buttonArray.value[0].focus();
currentFocusedIndex.value = 0;
break;
case 'End':
event.preventDefault();
buttonArray.value[buttonArray.value.length - 1].focus();
currentFocusedIndex.value = buttonArray.value.length - 1;
break;
case 'ArrowUp':
case 'Up':
event.preventDefault();
buttonArray.value[prevIndex.value].focus();
break;
case 'ArrowDown':
case 'Down':
event.preventDefault();
buttonArray.value[nextIndex.value].focus();
break;
case 'Space':
case 'Enter':
event.preventDefault();
toggleSelectedPanel(currentFocusedIndex.value);
}
}
Our handleKeyUp
method will be responsible for handling all our keyboard events. First, we check to see if a button is currently focused within the accordion. If there isn't a focused button, we exit early. Next, we destructure the code
from the event
, in which our switch
will handle the cases of which key was pressed. All of the following keys Home
, End
, ArrowUp
, and ArrowDown
adjust which button is focused in the accordion. The Space
and Enter
keys will trigger our toggleSelectedPanel
method.
Additionally, the function prevents the default actions for each key event. You might think we should move the event.preventDefault()
to the root of the method or perhaps use .prevent
on the event handler defined on the HTML element, but by doing either of these, we would break the behavior of the Tab
and possibly other keys. This way, we only prevent the default action for the keys we are looking for.
So now let's update our component markup to bind to the keyboard events:
<button
class="accordion-button"
type="button"
:id="`${section.slug}-heading`"
@keydown="handleKeyDown"
@focus="handleFocusIn"
@pointerup="toggleSelectedPanel(index)"
>
<slot :name="`${section.slug}-heading`">
{{ section.heading }}
</slot>
</button>
I've opted to use keydown
rather than keyup
in this scenario. The rationale behind this choice is to prevent inadvertent scrolling of the page when the Home
or End
keys are pressed. If we were to bind to keyup
, pressing Home
or End
would trigger page scrolling, potentially shifting the accordion out of view while maintaining focus. By using keydown
, we intercept these key events before the default action (scrolling) occurs, ensuring that the accordion remains in view and focused, thereby enhancing the user experience.
By now we should have a fairly complete component, lets review the full markup to this point:
<template>
<div
class="accordion-group"
ref="accordionGroupEl"
data-test-id="accordion-group"
>
<div
class="accordion"
v-for="(section, index) in sections"
:key="section.slug"
>
<component
class="accordion-heading"
:is="headingAs"
>
<button
class="accordion-button"
type="button"
:id="`${section.slug}-heading`"
@keydown="handleKeyDown"
@focus="handleFocusIn"
@pointerup="toggleSelectedPanel(index)"
>
<slot :name="`${section.slug}-heading`">
{{ section.heading }}
</slot>
</button>
</component>
<div
class="accordion-panel"
:id="section.slug"
v-show="currentExpandedIndex === index"
>
<slot :name="`${section.slug}-content`">
{{ section.content }}
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
sections: { type: Array, required: true },
headingAs: { type: String, default: 'h3' },
initialOpen: { type: String },
})
const accordionGroupEl = ref(null)
const buttonArray = ref([])
const currentFocusedIndex = ref(0)
const currentExpandedIndex = ref(-1)
const nextIndex = computed(() => {
return currentFocusedIndex.value === buttonArray.value.length - 1
? 0
: currentFocusedIndex.value + 1
});
const prevIndex = computed(() => {
return currentFocusedIndex.value === 0
? buttonArray.value.length - 1
: currentFocusedIndex.value - 1
});
const handleFocusIn = (event) => {
currentFocusedIndex.value = Array.prototype.indexOf.call(buttonArray.value, event.target);
}
const toggleSelectedPanel = (index) => {
currentExpandedIndex.value = currentExpandedIndex.value === index ? -1 : index;
}
const handleKeyDown = (event) => {
if (currentFocusedIndex.value === -1) return;
const { code } = event;
switch (code) {
case 'Home':
event.preventDefault();
buttonArray.value[0].focus();
currentFocusedIndex.value = 0;
break;
case 'End':
event.preventDefault();
buttonArray.value[buttonArray.value.length - 1].focus();
currentFocusedIndex.value = buttonArray.value.length - 1;
break;
case 'ArrowUp':
case 'Up':
event.preventDefault();
buttonArray.value[prevIndex.value].focus();
break;
case 'ArrowDown':
case 'Down':
event.preventDefault();
buttonArray.value[nextIndex.value].focus();
break;
case 'Space':
case 'Enter':
event.preventDefault();
toggleSelectedPanel(currentFocusedIndex.value);
}
}
onMounted(() => {
buttonArray.value = accordionGroupEl.value?.querySelectorAll('.accordion-button')
if(props.initialOpen !== undefined) {
const index = props.sections.findIndex(section => section.slug === props.initialOpen)
if(index !== -1) {
toggleSelectedPanel(index)
}
}
});
</script>
Aria Support
Finally, we need to add the appropriate ARIA attributes to our HTML elements. For detailed information about which attributes we'll be adding and their significance, please refer to the a11y requirements section. These ARIA attributes play a crucial role in ensuring accessibility for users who rely on assistive technologies, enhancing their understanding and navigation of the accordion component.
<button
class="accordion-button"
type="button"
:id="`${section.slug}-heading`"
:aria-expanded="currentExpandedIndex === index"
:aria-controls="section.slug"
@keydown="handleKeyUp"
@focus="handleFocusIn"
@pointerup="toggleSelectedPanel(index)"
>
<slot :name="`${section.slug}-heading`">
{{ section.heading }}
</slot>
</button>
<!-- other markup -->
<div
class="accordion-panel"
role="region"
:aria-labelledby="`${section.slug}-heading`"
:hidden="currentExpandedIndex !== index"
:id="section.slug"
v-show="currentExpandedIndex === index"
>
<slot :name="`${section.slug}-content`">
{{ section.content }}
</slot>
</div>
Conclusion
To wrap up, we've covered the process of creating an accessible-friendly accordion component using Vue 3. By following best practices for accessibility, we ensure that our component is not only functional but also inclusive and reliable for all users. In Part 2, we will cover writing unit tests for the accordion component. You can find part 2 here.