Tutorial

How to Create an Accessible Autocomplete Component with Vue.js

Published on February 28, 2018
Default avatar

By Filipa Lacerda

How to Create an Accessible Autocomplete Component with Vue.js

While we believe that this content benefits our community, we have not yet thoroughly reviewed it. If you have any suggestions for improvements, please let us know by clicking 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

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about us


About the authors
Default avatar
Filipa Lacerda

author

Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
Leave a comment


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Get our biweekly newsletter

Sign up for Infrastructure as a Newsletter.

Hollie's Hub for Good

Working on improving health and education, reducing inequality, and spurring economic growth? We'd like to help.

Become a contributor

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

Welcome to the developer cloud

DigitalOcean makes it simple to launch in the cloud and scale up as you grow — whether you're running one virtual machine or ten thousand.

Learn more
DigitalOcean Cloud Control Panel