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 or Space will expand the content (panel) could also close the content
  • Tab Moves focus between Headers
  • Down Arrow (optional) - Moves focus to the next header
  • Up Arrow (optional) - Moves focus to the prev header
  • Home (optional) - Moves focus to the first header
  • End (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 panel
  • role=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 of objects 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 root el 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:

TheAccordion.vue
<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.

View Demo on Vue SFC Playground

View Repo With Source

Back to home