Tutorial

How to Create an Accessible Autocomplete Component with Vue.js

Vue.js

While this tutorial has content that we believe is of great benefit to our community, we have not yet tested or edited it to ensure you have an error-free learning experience. It's on our list, and we're working on it! You can help us out by using the "report an issue" button at the bottom of the tutorial.

Remember this autocomplete component we’ve built? Although most users are able to use it, people with disabilities that require an assistive technology to browse the web won’t. That is because we didn’t make it semantic enough for these technologies to understand that our component is more than a regular input.

In this article were are going to learn how to use ARIA attributes to make our autocomplete into a fully accessible one.

Accessible Rich Internet Applications (ARIA)

Have you ever tried to browse the web with an assistive technology? Most operative systems come with an integrated solution, in MacOS you can open VoiceOver, by pressing cmd + F5 and on Windows you can start Narrator by pressing Windows logo key + Ctrl + Enter.

When we use one of the above with this autocomplete component it will tell us that the autocomplete is a text field and won’t inform us about the list of options.

We can change that with the help of ARIA attributes. The ARIA specification defines how to make the web content usable by people with disabilities by providing a set of attributes that allows assistive technology softwares to understand the semantics of the content.

Labels matter

You would be surprised on how much a simple label can improve usability.

Let’s quickly setup our component into an application and use VoiceOver to interact with it.

app.vue
<template>
<div id="app">
  <div>
    <label>Choose a fruit:</label>

    <autocomplete
      :items="[ 'Apple', 'Banana', 'Orange', 'Mango', 'Pear', 'Peach', 'Grape', 'Tangerine', 'Pineapple']"
    />
  </div>
</div>
</template>

When we enable VoiceOver to interact with our component we are only aware of the presence of a textfield but we have no idea for what it is for since the label isn’t picked up by the assistive technology.

VoiceOver edit text

By adding either aria-label or aria-labelledby attributes we’ll enable the user to know for what this input is for.

Let’s add a prop to our autocomplete for the aria-labelledby attribute. Note that you can choose to provide the aria-label instead, but because most autocomplete components have a label element nearby, I’m going to take advantage of that:

components/autocomplete.vue
<script>
export default {
  ...
  props {
    ...
    ariaLabelledBy: {
      type: String,
      required: true,
    },
  };
};
</script>
<template>
  ...
  <input
    type="text"
    v-model="search"
    @input="onChange"
    :aria-labelledby="ariaLabelledBy"
  />
  ...
</template>

I’ve made it a required attribute to make sure no one ever forgets to add it. If your application won’t ever have a label element surrounding the component it may be wiser to use the aria-label attribute instead.

We just need to add an id to our label and to provide it as a prop:

app.vue
<template>
<div id="app">
  <div>
    <label id="fruitLabel">Choose a fruit:</label>

    <autocomplete
      :items="[ 'Apple', 'Banana', 'Orange', 'Mango', 'Pear', 'Peach', 'Grape', 'Tangerine', 'Pineapple']"
      aria-labelled-by="fruitlabel"
    />
  </div>
</div>
</template>

And now the assistive technology is able to tell us that the input text intent is to choose a fruit:

Voice Over Choose a Fruit

ARIA attributes

Although labels can improve the usability tremendously, they are not enough, the user still doesn’t know it’s an autocomplete element. In order to do so we need to use other ARIA attributes.

Let’s start by understanding how the role attribute works.

Roles define the element type of the element. In here you can check all different types of roles.

The more suitable one for our autocomplete is the combobox one:

A composite widget containing a single-line textbox and another element, such as a listbox or grid, that can dynamically pop up to help the user set the value of the textbox.

Because the input of text in our component will display a list of results for the intended value we also need to set the aria-autocomplete attribute in the textbox element.

The aria-autocomplete attribute allows three different values, an inline value which defines that the value completion will happen inside the text input and a list value which means the values will be present in a separate element that pops up adjacent to the text input or a both value which means that a list of values will be displayed and when displayed one value in the list is automatically selected and will be visible inside the text input.

Because our list of options is in a separate element, we’ll need to use the list value.

This attribute alone doesn’t magically know where our list of values is in the document, so we need to specify that by using the aria-controls attribute.

We also need to ensure our autocomplete is identified with the aria-haspopup attribute and that our container has a aria-expanded attribute set whenever the list of results is visible.

Last but not least, we also need to add the role attribute to our input with the searchbox value, to the ul element with listbox and to each li with role value.

With these attributes the assistive technology software is now able to understand we are presenting the user with a combobox that will show a list of suggested values.

components/autocomplete.vue
<template>
  <div
    class="autocomplete"
    role="combobox"
    aria-haspopup="listbox"
    aria-owns="autocomplete-results"
    :aria-expanded="isOpen"
  >
    <input
      type="text"
      @input="onChange"
      v-model="search"
      @keyup.down="onArrowDown" @keyup.up="onArrowUp" @keyup.enter="onEnter" aria-multiline="false"
      role="searchbox"
      aria-autocomplete="list"
      aria-controls="autocomplete-results"
      aria-activedescendant=""
      :aria-labelledby="ariaLabelledBy"
    />
    <ul
      id="autocomplete-results"
      v-show="isOpen"
      class="autocomplete-results"
      role="listbox"
    >
      <li class="loading" v-if="isLoading">
        Loading results...
      </li>
      <li
        v-else
        v-for="(result, i) in results"
        :key="i"
        @click="setResult(result)" class="autocomplete-result"
        :class="{ 'is-active': i === arrowCounter }"
        role="option"
      >
        {{ result }}
      </li>
    </ul>
  </div>
</template>

Voice Over Combobox

Arrows Support

Remember that we added keyboard support in our autocomplete component? We need to manage it with ARIA attributes too.

In order for the assistive technology to know which option is selected when we use the arrow keys, we’ll need to set two attributes:

The aria-activedescendant needs to be set in the input field and it will hold the ID of the option which is visually identified as having keyboard focus.

And the aria-selected one needs to be set in the li attribute in the option visually highlighted as selected.

One important thing we need to update in our component are the listeners, in order for the assistive technology to correctly identify which option is active, we need to listen to the keydown event instead of the keyup event.


You can see the full source code in the following snippet or in this codepen.

components/autocomplete.vue
<script>
  export default {
    name: 'autocomplete',
    props: {
      items: {
        type: Array,
        required: false,
        default: () => [],
      },
      isAsync: {
        type: Boolean,
        required: false,
        default: false,
      },
      ariaLabelledBy: {
        type: String,
        required: true
      }
    },

    data() {
      return {
        isOpen: false,
        results: [],
        search: '',
        isLoading: false,
        arrowCounter: 0,
        activedescendant: ''
      };
    },

    methods: {
      onChange() {
        this.$emit('input', this.search);
        if (this.isAsync) {
          this.isLoading = true;
        } else {
          this.filterResults();
        }
      },

      filterResults() {
        this.results = this.items.filter((item) => {
          return item.toLowerCase().indexOf(this.search.toLowerCase()) > -1;
        });
      },
      setResult(result) {
        this.search = result;
        this.isOpen = false;
      },
      onArrowDown(evt) {
        if (this.isOpen) {
          if (this.arrowCounter < this.results.length) {
            this.arrowCounter = this.arrowCounter + 1;
            this.setActiveDescendent();
          }
        }
      },
      onArrowUp() {
        if (this.isOpen) {
          if (this.arrowCounter > 0) {
            this.arrowCounter = this.arrowCounter -1;
            this.setActiveDescendent();
          }
        }
      },
      onEnter() {
        this.search = this.results[this.arrowCounter];
        this.arrowCounter = -1;
      },
      handleClickOutside(evt) {
        if (!this.$el.contains(evt.target)) {
          this.isOpen = false;
          this.arrowCounter = -1;
        }
      },
      setActiveDescendant() {
        this.activedescendant = this.getId(this.arrowCounter);
      },
      getId(index) {
        return `result-item-${index}`;
      },
      isSelected(i) {
        return i === this.arrowCounter;
      },
    },
    watch: {
      items: function (val, oldValue) {
        // actually compare them
        if (val.length !== oldValue.length) {
          this.results = val;
          this.isLoading = false;
        }
      },
    },
    mounted() {
      document.addEventListener('click', this.handleClickOutside)
    },
    destroyed() {
      document.removeEventListener('click', this.handleClickOutside)
    }
  };
</script>
</script>
<template>
  <div
    class="autocomplete"
    role="combobox"
    aria-haspopup="listbox"
    aria-owns="autocomplete-results"
    :aria-expanded="isOpen"
  >
    <input
      type="text"
      @input="onChange"
      @focus="onFocus"
      v-model="search"
      @keydown.down="onArrowDown"
      @keydown.up="onArrowUp"
      @keydown.enter="onEnter"
      role="searchbox"
      aria-autocomplete="list"
      aria-controls="autocomplete-results"
      :aria-labelledby="ariaLabelledBy"
      :aria-activedescendant="activedescendant"
    />
    <ul
      id="autocomplete-results"
      v-show="isOpen"
      class="autocomplete-results"
      role="listbox"
    >
      <li
        class="loading"
        v-if="isLoading"
      >
        Loading results...
      </li>
      <li
        v-else
        v-for="(result, i) in results"
        :key="i"
        @click="setResult(result)"
        class="autocomplete-result"
        :class="{ 'is-active': isSelected(i) }"
        role="option"
        :id="getId(i)"
        :aria-selected="isSelected(i)"
      >
        {{ result }}
      </li>
    </ul>
  </div>
</template>

Autocomplete accessibility cheatsheet

Here you can find a cheatsheet with all the ARIA attributes you’ll need to make an autocomplete accessible.

Element Attribute Value Usage
div role combobox Identifies the element as a combobox
div aria-haspopup listbox Identifies that the element will popup a lisbox with the suggested values
div aria-owns IDREF Identifies the element with the suggested list values
div aria-expanded true Indicates whether the list of suggested values is currently expanded or collapsed
input role searchbox Identifies the element as a searchbox
input aria-labelledby IDREF Provides a label for the combobox element
input aria-autocomplete list Indicates that when a user is providing input an element containing a list of suggested values will be displayed
input aria-controls IDREF
input aria-activedescendant IDREF When an option in the list of results is visually identified as having keyboard focus, it will refer to that option
ul role listbox Identifies the element as a listbox
li role option Identifies the element as a listbox option
li aria-selected true Identifies the element as being visually idenfied as selected

IDREF: A reference to an element’s ID attribute

Creative Commons License