An accessible custom dropdown menu in Vue.js

Designers and developers often want to bring a dropdown menu in line with a website’s design. To achieve that they either add styling to <select> elements where possible or result to using other HTML elements that partially mimic the <select> behaviour. None of these approaches are ideal, because they have either styling or accessibility limitations.

In this article I explore a third approach which is using the ARIA (Accessible Rich Internet Applications) attribute role="listbox" to create a custom accessible dropdown menu. This role can be added to any HTML element which we can easily customise to match our styling needs while making the dropdown menus fully accessible.

My implementation is based on the W3C Collapsible Dropdown Listbox Example but using Vue.js. The component works like any other Vue.js form component because it implements the v-model directive (read more here). You can access the full demo here.

Aria role “listbox”

Before delving into code, it’s essential to understand the ARIA role “listbox” and how we can use it to implement a custom dropdown menu. This role indicates a list of options from which a user can select one or more of them.

Screen readers are able to read HTML elements with role="listbox" along with their labels and their options (role="option"). As a result, listboxes are screen reader friendly in a similar fashion to to native <select> elements.

The HTML

Starting with the HTML for our accessible custom dropdown, the key attributes that we need to include are:

  • A label for the listbox (referenced with aria-labelledby)
  • A listbox element (with role="listbox")
  • A set of options nested inside the listbox (with role="option")

The markup that I will use for my implementation is:

<div class="listbox">
  <span class="listbox__label" id="listbox-label">Choose an element:</span>
  <div class="listbox__dropdown">
    <button aria-haspopup="listbox" aria-labelledby="listbox-label listbox-button" id="listbox-button">
      Option 1
    </button>
    <ul id="listbox-list" tabindex="-1" role="listbox" aria-labelledby="listbox-button" style="display: none;">
      <li id="option-1" role="option" aria-selected="true">Option 1</li>
      <li id="option-2" role="option">Option 2</li>
      <li id="option-3" role="option">Option 3</li>
    </ul>
  </div>
</div>

In this example, I use a concatenated label for our dropdown menu which reads “Choose an element [selected option]” and where [selected option] is the option with the attribute aria-selected="true". I also use a button with the attribute aria-haspopup="listbox" to indicate that the button will change the visibility of the element with role="listbox".

The interactivity

The ARIA labels in the example HTML above make the listbox more accessible for screen readers. However, I still need to add interactivity for it to be able to mimic the browser’s native <select> dropdown behaviour.

The interactivity that I need to add boils down to the following functionality:

  • A click handler for the button (id="listbox-button") to toggle the visibility of the options list with role="listbox".
  • A keydown handler for the same button to show the list of options when the up or down arrow keys are pressed.
  • A click handler for the list of options (role="listbox) to select an option when one is clicked.
  • A keydown handler for the same list to interpret the keyboard navigation and select the appropriate option.

Additionally to the above, I will add another handler that checks whether the user clicked outside the listbox in order to hide the list of options when they do.

Let’s see now how the HTML and the interactivity above come together in a Vue.js component in the next section.

A Vue.js listbox component

In this section I break down my custom Vue.js Listbox component in the three parts it consists of: the template (<template>), the functionality (<scripts>) and the styling (<style>). I explain each part in detail to allow you to modify it if needed to suit your project’s needs. For my component I’m using Vue.js 2, babel and SCSS.

The template

Starting with the template for my custom component, I’ve translated the HTML described in The HTML section in Vue.js code, adding dynamic attributes and event handlers where needed.

<template>
  <div class="listbox" :value="value" ref="listbox">
    <span
      :id="`${name}-label`"
      :class="{ 'listbox__label--visually-hidden': !showLabel }"
      class="listbox__label"
    >
      {{ label }}
    </span>
    <div class="listbox__dropdown" v-click-outside="hideListbox">
      <button
        :id="`${name}-button-label`"
        :aria-labelledby="`${name}-label ${name}-button-label`"
        type="button"
        aria-haspopup="listbox"
        class="listbox__button"
        @click="toggleListbox"
        @keydown="checkShow"
        ref="listboxButton"
      >
        {{ value ? selectedOptionLabel : emptyOptionLabel }}
      </button>
      <ul
        v-show="!listboxHidden"
        :aria-labelledby="`${name}-label`"
        :aria-activedescendant="value"
        tabindex="-1"
        role="listbox"
        class="listbox__list"
        @keydown="checkKeyDown"
        @click="checkClickItem"
        ref="listboxNode"
      >
        <li
          v-for="(option, index) in options"
          :key="`${name}-option-${index}`"
          :aria-selected="option.value === value"
          :data-value="option.value"
          class="listbox__option"
          role="option"
          ref="listboxOptions"
        >
          {{ option.label }}
        </li>
      </ul>
    </div>
  </div>
</template>

This template reflects the listbox HTML with the added interactive features:

  • a value bound to the component’s value property (line 2) that is required when using v-model on custom components (read more here).
  • a click outside handler (v-click-outside) named hideListbox (line 10) that checks whether the user clicked outside the listbox and hides the dropdown if so.
  • a click handler named toggleListbox for the button (line 17) that toggles the visibility of the list of options.
  • a keyDown handler named checkShow for the button (line 18) that shows the list of options when the up or down arrow keys are pressed
  • a keyDown handler named checkKeyDown for the list of options (line 30) that interprets keyboard navigation and selects the appropriate option.
  • a click handler named checkClickItem for the listbox (line 31) that selects an option when it’s clicked.

As well as the added interactivity the template includes ref attributes for the listbox (line 2), the listbox button (line 19), the listbox list (line 32) and each listbox option (line 41). The ref attribute allows us to assign a reference ID to a child element, which we can quickly access inside our component as this.$refs['reference-ID']. This feature is very handy in this case to manually trigger events (e.g. focus) and update attributes for a specific element without needing additional component data/props.

Additionally, I conditionally print the class .listbox__label--visually-hidden for the listbox label (line 5). This label goes above the dropdown menu and will only be shown to non screen readers if showLabel is equal to true.

Lastly, on line 21 I’m using an emptyOptionLabel property to provide a default label when no option in the listbox is selected (e.g. “Choose a value”). This can be handy when you don’t want your dropdown menu to have a preselected option.

The functionality

Probably the most complicated part of this component is its functionality. In order to digest its inner workings more easily, I’ve broken it down in three parts: the variables, the methods and the additional functionality.

Component variables

Starting with the component variables, we’ll take a further look into the data attributes, the properties and the computed properties that make up the Listbox component.

Data attributes

The data attributes that I used in this component are:

  • keyClear – saves a timeout for which we listen for character keyboard events.
  • keysSoFar – stores the characters typed by the user in the defined timeout (keyClear).
  • listboxHidden – marks whether the list of options is hidden or not (true/false).
  • searchIndex – stores the index of the selected option. I use this index when searching for the next match in the list of options after a user has typed a set of characters.

In practice these data attributes are defined as follows:

data: () => ({
  keyClear: null,
  keysSoFar: '',
  listboxHidden: true,
  searchIndex: null,
}),
Properties

The properties for this component are:

  • value – the selected listbox option. It’s a required property when using v-model on custom components (read more here).
  • label – a label to show above the listbox that can be visually hidden for non screen readers.
  • options – an array of options (objects) for the listbox. Each listbox option needs to have the following format:
    { value: "option-1", label: "Option 1" }
  • name – a unique string to identify this dropdown from other ones used on the same page.
  • emptyOptionLabel – the text to show when there’s no preselected option in the listbox.
  • showLabel – whether to show the label above the listbox.

In practice these properties are defined as follows:

props: {
  value: {
    type: String,
  },
  label: {
    type: String,
    required: true,
  },
  options: {
    type: Array,
    required: true,
  },
  name: {
    type: String,
    required: true,
  },
  emptyOptionLabel: {
    type: String,
    default: "Choose a value",
  },
  showLabel: {
    type: Boolean,
    default: false,
  },
},

Based on the above properties, here is an example use of this custom dropdown:

<listbox
  :options="options"
  :show-label="true"
  v-model="listboxValue"
  name="options"
  label="Pick an option below"
  empty-option-label="Select... "
/>

Notice how I bind the ListBox value (v-model) with the data property listboxValue of the parent component. The parent component’s listboxValue data attribute will now update with every Listbox value change.

Here is the output of the example use above:

Accessible Vue.js dropdown menu with label
Computed properties

The only computed property used in this component is the selectedOptionLabel which returns the label of the selected option. This property is calculated as follows:

computed: {
  selectedOptionLabel() {
    const selectedOption = this.options.find(option => option.value === this.value);
    return selectedOption && selectedOption.label;
  },
},

Component methods

Continuing with the methods for this component, in this section I explain each one as it appears in the Vue.js template alongside any other methods called within it. I will skip the handler for the custom directive v-click-outside as I will expand on this one further in a later section.

toggleListBox

The first method in the template is attached to the click handler for the listbox button (.listbox__button). This method toggles the visibility of the list of options (.listbox__list) when a user clicks on it.

In practice this method calls the methods showListbox or hideListbox based on whether the listbox list is hidden or visible as shown below.

// Toggles Listbox based on this.listboxHidden
toggleListbox() {
  this.listboxHidden ? this.showListbox() : this.hideListbox();
},
// Shows the ListBox list and puts its on focus
showListbox() {
  this.listboxHidden = false;
  this.$refs.listboxButton.setAttribute("aria-expanded", "true");
  this.$refs.listboxNode.focus();
},
// Hides the ListBox list
hideListbox() {
  this.listboxHidden = true;
  this.$refs.listboxButton.removeAttribute("aria-expanded");
},

Notice how I update the attribute “aria-expanded” for the listbox list depending on whether the list is shown (aria-expanded="true") or hidden (removing aria-expanded attribute). This change notifies screen readers about the dropdown state (expanded/collapsed).

checkShow

The next event handler in the template is checkShow for the listbox button keyDown event. This is a simple method that listens for the up and down keyboard events in order to show the listbox options. The code for checkShow is as follows:

/**
 * Keypress handler for the listbox button.
 * It shows the listbox list on up/down key press.
 */
checkShow(event) {
  const key = event.which || event.keyCode;

  switch (key) {
    case keyCodes.UP:
    case keyCodes.DOWN:
      event.preventDefault();
      this.showListbox();
      this.checkKeyDown(event);
      break;
    default:
      break;
  }
},
checkKeyDown

This method is responsible for interpreting keyboard events inside the list of options. It’s called in checkShow and it’s also the keyDown handler for the listbox list (.listbox__list). Let’s take a look at the code and go through what it does step by step.

/**
 * Handles various keyboard controls; UP/DOWN/HOME/END/PAGE_UP/PAGE_DOWN
 *
 * @param {Event} event The keydown event object
 */
checkKeyDown(event) {
  const key = event.which || event.keyCode;
  
  switch (key) {
    case keyCodes.UP:
    case keyCodes.DOWN:
      event.preventDefault();
      const selectedItemIndex = this.options.findIndex(option => option.value === this.value);
      let nextItem = selectedItemIndex ? this.$refs.listboxOptions[selectedItemIndex]: this.$refs.listboxOptions[0];

      if (key === keyCodes.UP) {
        // If there's an option above the selected one
        if (selectedItemIndex - 1 >= 0) {
          // Assign the previous option to nextItem
          nextItem = this.$refs.listboxOptions[selectedItemIndex - 1];
        }
      } else {
        // If there's an option below the selected one
        if (selectedItemIndex + 1 <= this.options.length) {
          nextItem = this.$refs.listboxOptions[selectedItemIndex + 1];
        }
      }

      if (nextItem) {
        this.focusItem(nextItem);
      }

      break;
    case keyCodes.HOME:
    case keyCodes.PAGE_UP:
      event.preventDefault();
      this.focusFirstItem();
      break;
    case keyCodes.END:
    case keyCodes.PAGE_DOWN:
      event.preventDefault();
      this.focusLastItem();
      break;
    case keyCodes.RETURN:
    case keyCodes.ESC:
      event.preventDefault();
      this.hideListbox();
      this.$refs.listboxButton.focus();
      break;
    default: {
      // If the user typed a set of characters,
      // focus the option that matches those characters
      const itemToFocus = this.findItemToFocus(key);
      if (itemToFocus) {
        this.focusItem(itemToFocus);
      }
      break;
    }
  }
},

Firstly, I read the key that triggered the keyDown event (event.which || event.keyCode). I then use the key to trigger the appropriate action using a switch/case statement. The switch cases and their corresponding actions are as follows:

  • The up or down key was pressed: I prevent the default browser behaviour (line 12) and I find & set a default option to select next (lines 13-14). If the up arrow was pressed, I set the option to select next to be the one before the selected one (lines 16-21). On the contrary, when the down arrow is pressed, I set the option to select next to be the one after the selected one if any (lines 22-27). If after all these checks there is an option to select, I call the method focusItem for that option (lines 29-31).
  • The home or page up key was pressed: I prevent the default browser behaviour and select the first option in the list of options by calling the method focusFirstItem (lines 35-39).
  • The end or page down key was pressed: I prevent the default browser behaviour and select the last option in the list of options by calling the method focusLastItem (lines 40-44).
  • The enter or the escape key was pressed: I prevent the default browser behaviour and hide the listbox list (lines 45-48). I also set the focus to the listbox button (line 49).
  • Default: I search through the options and select one if the keys pressed are partially matching the option’s label (lines 51-59)

Almost every case described above corresponds to a new selection in the list of options. In order to select an option I use four different methods; focusFirstItem, focusLastItem and focusItem which I explain in detail below.

focusFirstItem

This function is called inside checkKeyDown and it selects the first option in the list of options. In practice it looks like this:

/**
 *  Focus on the first option
 */
focusFirstItem() {
  this.focusItem(this.$refs.listboxOptions[0]);
},

I use the method focusItem to select an option, which I’ll go through in detail in a later section. Notice how I call focusItem for the first option in the array of options since I know that the options array is a required property and cannot be undefined.

focusLastItem

Conversely to focusFirstItem, this method selects the last option in the list of options as shown below.

/**
 *  Focus on the last option
 */
focusLastItem() {
  const lastListboxOption = this.$refs.listboxOptions[this.options.length - 1];
  this.focusItem(lastListboxOption);
},
focusItem

As previously mentioned focusItem is the method I call to select an option from the list of options. The code for this method is as follows:

/**
 * Select the option passed as the parameter.
 *
 * @param {Element} element - the option to select
 */
focusItem(element) {
  // Defocus active element
  if (this.value) {
    const index = this.options.findIndex(option => option.value === this.value);
    const listboxOption = this.$refs.listboxOptions[index];
    this.defocusItem(listboxOption);
  }
  element.setAttribute("aria-selected", "true");
  this.$refs.listboxNode.setAttribute(
    "aria-activedescendant",
    element.getAttribute('data-value')
  );
  // Trigger the v-model "input" event with value equal to element.id
  this.$emit("input", element.getAttribute('data-value'));

  // Scroll up/down to show the listbox within the viewport
  if (this.$refs.listboxNode.scrollHeight > this.$refs.listboxNode.clientHeight) {
    const scrollBottom = this.$refs.listboxNode.clientHeight + this.$refs.listboxNode.scrollTop;
    const elementBottom = element.offsetTop + element.offsetHeight;
    
    if (elementBottom > scrollBottom) {
      this.$refs.listboxNode.scrollTop = elementBottom - this.$refs.listboxNode.clientHeight;
    } else if (element.offsetTop < this.$refs.listboxNode.scrollTop) {
      this.$refs.listboxNode.scrollTop = element.offsetTop;
    }
  }
},

Firstly, I check whether an option is already selected in our dropdown and remove the focus from it (lines 7-12). I then update the aria attributes for the new selected option and the options list and trigger an input event for our v-model component (lines 13-19). Triggering the input event will notify the parent component for the value change.

Finally, I check whether the options list is visible within the browser’s viewport and scroll the page if not to reveal the list (lines 21-31).

defocusItem

This method removes the aria-selected attribute for the HTML element passed as the parameter.

/**
 * defocus on the element passed as a parameter.
 *
 * @param {Element} element
 */
defocusItem(element) {
  if (!element) {
    return;
  }
  element.removeAttribute("aria-selected");
},
findItemToFocus

This method is called when a user types a set of characters inside the list of options. It searches for an option label that contains the set of characters and if it finds a match, it selects that option.

/**
  * Returns an option that its label matches the key or
  * null if none of the match the key entered.
  *
  * @param {String} key typed characters to check whether they match an option
  */
findItemToFocus(key) {
  const character = String.fromCharCode(key);

  // If it's the first time the user is typing to find an option
  // set the search index to the active option
  if (!this.keysSoFar) {
    this.searchIndex = this.options.findIndex(option => option.value === this.value);
  }

  this.keysSoFar += character;
  this.clearKeysSoFarAfterDelay();

  // Find the next matching element starting from the search index
  // until the end of all the options
  let nextMatch = this.findMatchInOptions(
    this.searchIndex + 1,
    this.options.length
  );

  // If there wasn't a match search for a match from the start of 
  // all the options until the search index
  if (!nextMatch) {
    nextMatch = this.findMatchInOptions(0, this.searchIndex);
  }

  return nextMatch;
},

Let’s go through what this method does step by step:

  • It reads the key typed by the user as a String character (line 8).
  • If it’s the first time the user typed a character inside the options list, it sets the searchIndex data attribute as the index of the selected option (lines 10-14).
  • It adds the String character to the data attribute keysSoFar (line 16) which contains the characters typed in the timeout we’ve set (keyClear).
  • Then it resets the timeout for which we listen for character keyboard events (line 17).
  • It looks for an option that matches the typed characters starting from the index – searchIndex that we’ve set earlier (lines 19-24).
  • If it hasn’t found a match in the previous step, it looks for matches in the list before the set index – searchIndex (lines 26-30).
  • Finally it returns any match it has found or null if it hasn’t found any (line 32).
clearKeysSoFarAfterDelay

Once the user types a String character in the list of options I set a timeout of 500 milliseconds to wait in case the user types another character. The more characters the user types the more accurate the match in the list of options will be.

When the user types more than one character, I reset the timeout to keep listening for more characters. When the time is up, I clear the timeout and the keys typed so far.

// Resets the keysSoFar after 500ms
clearKeysSoFarAfterDelay() {
  if (this.keyClear) {
    clearTimeout(this.keyClear);
    this.keyClear = null;
  }
  this.keyClear = setTimeout(() => {
    this.keysSoFar = "";
    this.keyClear = null;
  }, 500);
},
findMatchInOptions

This method looks for an option that starts with the characters typed by the user. If a match is found, it returns the option element or otherwise it returns null.

/**
 * Returns an element that its label starts with keysSoFar
 * or null if none is found in the options.
 * 
 * @param {String} keysSoFar
 */
findMatchInOptions(startIndex, endIndex) {
  for (let i = startIndex; i < endIndex; i++) {
    if (
      this.options[i].label &&
      this.options[i].label
        .toUpperCase()
        .indexOf(this.keysSoFar) === 0
    ) {
      return this.$refs.listboxOptions[i];
    }
  }
  return null;
},
checkClickItem

The last method referenced in the Vue.js template is checkClickItem. This method is a click handler for the list of options. It checks whether the click was triggered for an option and if so, sets the option as the selected one, hides the list of options and sets the focus to the listbox button as shown below:

/**
 * On option click it focuses on the option.
 *
 * @param {Event} event
 */
checkClickItem(event) {
  if (event.target.getAttribute("role") === "option") {
    this.focusItem(event.target);
    this.hideListbox();
    this.$refs.listboxButton.focus();
  }
},

Additional component functionality

In addition to the functionality proposed in W3C’s Listbox implementation, I decided to include a handler that detects whether the user clicked outside the Listbox dropdown and hide the dropdown if so. This interactivity further matches the native <select> dropdown menus that users are accustomed to.

After some research on potential ways to solve this problem, I came across this Stackoverflow answer that proposes to trigger a “click-outside” event using a custom Vue.js directive. This custom directive triggers the custom event ‘click-outside’ whenever an element other than the targeted one is clicked.

In practice this directive is registered like this:

import Vue from 'vue';

Vue.directive('click-outside', {
  bind: function (el, binding, vnode) {
    el.clickOutsideEvent = function (event) {
      // if the click event was triggered for an element outside the target element
      if (!(el === event.target || el.contains(event.target))) {
        // trigger the event handler with event as an argument
        vnode.context[binding.expression](event);
      }
    };
    document.body.addEventListener('click', el.clickOutsideEvent);
  },
  unbind: function (el) {
    document.body.removeEventListener('click', el.clickOutsideEvent);
  },
});

And used with the example handler hideListbox like this:

<div v-click-outside="hideListbox" />

The styling

Finally, since we’re using divs, buttons and lists for our markup we need to add some styling to make the listbox look closer to a <select> dropdown menu. For this example the styling is the following:

<style lang="scss">
.listbox {
  font-size: 0;
  font-family: Arial, Helvetica, sans-serif;
}

[role="listbox"] {
  min-height: 18em;
  padding: 0;
  background: white;
  border: 1px solid #aaa;
}

[role="option"] {
  display: block;
  padding: 0 1em 0 1.5em;
  position: relative;
  line-height: 1.8em;
  font-size: 1rem;

  &.focused {
    background: #bde4ff;
  }
}

[role="option"][aria-selected="true"]::before {
  content: "✓";
  position: absolute;
  left: 0.5em;
}

button {
  font-size: 16px;

  &[aria-disabled="true"] {
    opacity: 0.5;
  }
}

.listbox__button {
  font-size: 1rem;
  text-align: left;
  padding: 5px 10px;
  width: 150px;
  position: relative;

  &:after {
    width: 0;
    height: 0;
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-top: 5px solid #6bc2d6;
    content: " ";
    position: absolute;
    right: 5px;
    top: 50%;
    transform: translateY(-50%);
  }

  &[aria-expanded="true"]::after {
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-top: 0;
    border-bottom: 5px solid #6bc2d6;
  }
}

.listbox__list {
  border-top: 0;
  max-height: 10rem;
  overflow-y: auto;
  position: absolute;
  margin: 0;
  width: 148px;
}

.listbox__label {
  font-size: 1rem;
  padding-bottom: 0.5rem;
  display: inline-block;
}

.listbox__label--visually-hidden {
  position: absolute;
  height: 1px;
  width: 1px;
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
  white-space: nowrap;
}
</style>

Which outputs the following dropdown menu:

Accessible Vue.js dropdown menu with visually hidden label

References