How to Create an Accessible Autocomplete Component with Vue.js

Filipa Lacerda

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"
      >
        
      </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)"
      >
        
      </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.

ElementAttributeValueUsage
divrolecomboboxIdentifies the element as a combobox
divaria-haspopuplistboxIdentifies that the element will popup a lisbox with the suggested values
divaria-ownsIDREFIdentifies the element with the suggested list values
divaria-expandedtrueIndicates whether the list of suggested values is currently expanded or collapsed
inputrolesearchboxIdentifies the element as a searchbox
inputaria-labelledbyIDREFProvides a label for the combobox element
inputaria-autocompletelistIndicates that when a user is providing input an element containing a list of suggested values will be displayed
inputaria-controlsIDREF 
inputaria-activedescendantIDREFWhen an option in the list of results is visually identified as having keyboard focus, it will refer to that option
ulrolelistboxIdentifies the element as a listbox
liroleoptionIdentifies the element as a listbox option
liaria-selectedtrueIdentifies the element as being visually idenfied as selected

IDREF: A reference to an element's ID attribute

  Tweet It

🕵 Search Results

🔎 Searching...