Tutorial

How To Build an Autocomplete Component with Vue.js

Vue.js

Introduction

Autocompletion is a common modern feature. When a user interacts with an input field, they are provided with a list of suggested values that are relevant to what they typed in.

In this article, you will learn how to make an autocomplete component using v-model, how to handle events with key modifiers, and how to prepare it for async requests.

Prerequisites

To complete this tutorial, you will need:

This tutorial was verified with Node v15.3.0, npm v6.14.9, and vue v2.6.11.

Step 1 — Setting Up the Project

For the purpose of this tutorial, you will build from a default Vue project generated with @vue/cli.

  • npx @vue/cli create vue-autocomplete-component-example --default

This will configure a new Vue project with default configurations: Vue 2, babel, eslint.

Navigate to the newly created project directory:

  • cd vue-autocomplete-component-example

Now, you can use your code editor to create a new autocomplete component. This will be a single-file Vue component with a template, scripts, and styles.

In order to build an autocomplete component, your template will need at least two things: an input and a list.

src/components/SearchAutocomplete.vue
<template>
  <div class="autocomplete">
    <input
      type="text"
    />
    <ul
      class="autocomplete-results"
    >
      <li
        class="autocomplete-result"
      >
        (result)
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'SearchAutocomplete'
};
</script>

This code will create a div with an input for entering text and an unordered list for displaying autocomplete results.

Next, provide some styling for the component in the same file:

src/components/SearchAutocomplete.vue
<style>
  .autocomplete {
    position: relative;
  }

  .autocomplete-results {
    padding: 0;
    margin: 0;
    border: 1px solid #eeeeee;
    height: 120px;
    min-height: 1em;
    max-height: 6em;    
    overflow: auto;
  }

  .autocomplete-result {
    list-style: none;
    text-align: left;
    padding: 4px 2px;
    cursor: pointer;
  }

  .autocomplete-result:hover {
    background-color: #4AAE9B;
    color: white;
  }
</style>

Step 2 — Filtering the Search Results

When the user types, you will want to show them a list of results. Your code will need to listen for input changes to know when to show those results.

In order to do so you will make use of v-model. v-model is a directive with two way data-binding for form inputs and textareas that updates the data on user input events.

Because you will need to know when the user has finished typing to filter the results, you will also add an event listener for @input.

src/components/SearchAutocomplete.vue
<template>
  <div class="autocomplete">
    <input
      v-model="search"
      @input="onChange"
      type="text"
    />
    <ul
      class="autocomplete-results"
    >
      <li
        class="autocomplete-result"
      >
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'SearchAutocomplete',
  data() {
    return {
      search: '',
    };
  },
  methods: {
    onChange() {
      // ...
    }
  }
};
</script>

Now that you know what to search for and when to do it, you will need some data to show.

For the purposes of this tutorial, you will use an array of values, but you can update the filter function to handle more complex data structures.

Open App.vue and modify it to import and reference the autocomplete component:

src/App.vue
<template>
  <div id="app">
    <SearchAutocomplete
      :items="[
        'Apple',
        'Banana',
        'Orange',
        'Mango',
        'Pear',
        'Peach',
        'Grape',
        'Tangerine',
        'Pineapple'
      ]"
    />
  </div>
</template>

<script>
import SearchAutocomplete from './components/SearchAutocomplete.vue'

export default {
  name: 'App',
  components: {
    SearchAutocomplete
  }
}
</script>

Next, revisit SearchAutocomplete.vue in your code editor and add code to filter search results and display them:

src/components/SearchAutocomplete.vue
<template>
  <div class="autocomplete">
    <input
      v-model="search"
      @input="onChange"
      type="text"
    />
    <ul
      v-show="isOpen"
      class="autocomplete-results"
    >
      <li
        v-for="(result, i) in results"
        :key="i"
        class="autocomplete-result"
      >
        {{ result }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'SearchAutocomplete',
  props: {
    items: {
      type: Array,
      required: false,
      default: () => [],
    },
  },
  data() {
    return {
      search: '',
      results: [],
      isOpen: false,
    };
  },
  methods: {
    filterResults() {
      this.results = this.items.filter(item => item.toLowerCase().indexOf(this.search.toLowerCase()) > -1);
    },
    onChange() {
      this.filterResults();
      this.isOpen = true;
    }
  },
}
</script>

In filterResults(), notice how toLowerCase() is applied to both the typed text and each element of the array. This ensures that a user can either use capital or lowercase words and still get relevant results.

You also need to make sure that you only show the list of results after the user has typed something. You can accomplish that by conditionally displaying it with the usage of v-show.

Note: The reason to use v-show instead of v-if is that the visibility of this list will often be toggled and although the initial render cost of v-show is higher, v-if has higher toggle costs.

Step 3 — Updating Search Results with Click Events

Now you will need to make sure the list of results is usable. You will want users to be able to click on one of the results and automatically show that value as the chosen one.

You can accomplish that by listening to the click event and setting the value as the search term:

src/components/SearchAutocomplete.vue
<template>
  <div class="autocomplete">
    <input
      v-model="search"
      @input="onChange"
      type="text"
    />
    <ul
      v-show="isOpen"
      class="autocomplete-results"
    >
      <li
        v-for="(result, i) in results"
        :key="i"
        @click="setResult(result)"
        class="autocomplete-result"
      >
        {{ result }}
      </li>
    </ul>
  </div>
</template>

You will also need to close the list of results once that happens for a better user experience:

src/components/SearchAutocomplete.vue
<script>
export default {
  name: 'SearchAutocomplete',
  // ...
  methods: {
    setResult(result) {
      this.search = result;
      this.isOpen = false;
    },
    // ...
  },
}
</script>

To close the list of results once the user clicks off the results list you will need to listen to a click event outside this component. Let’s accomplish that once the component is mounted and when the user clicks somewhere you will need to check if it was outside of the component. You will use Node.contains to check if the event target belongs to the component.

src/components/SearchAutocomplete.vue
<script>
export default {
  name: 'SearchAutocomplete',
  // ...
  mounted() {
    document.addEventListener('click', this.handleClickOutside);
  },
  destroyed() {
    document.removeEventListener('click', this.handleClickOutside);
  }
  methods: {
    // ...
    handleClickOutside(event) {
      if (!this.$el.contains(event.target)) {
        this.isOpen = false;
      }
    }
  },
}
</script>

At this point, you can compile your application:

  • npm run serve

Then, open it in a web browser. You will be able to interact with the autocomplete component and ensure that it suggests items from the array.

Step 4 — Supporting Arrow Key Navigation

Let’s add an event listener for both UP, DOWN and ENTER keys. Thanks to key modifiers, Vue provides aliases for the most commonly used keys, so you will not need to verify which keycode belongs to each key.

In order to track which result is being selected, you will add an arrowCounter to hold the value for the current index in the search results array. Set the initial value of arrowCounter to -1 to guarantee that no option is selected before the user actively selects one. When the user presses the DOWN key, increase the arrowCounter value by 1. When the user presses the UP key, decrease the arrowCounter value by 1.

When the user presses the ENTER key, you will need to get that index from the array of results.

You will need to be careful to not keep counting once the list ends and not to start counting before the results are visible.

src/components/SearchAutocomplete.vue
<template>
  <div class="autocomplete">
    <input
      v-model="search"
      @input="onChange"
      @keydown.down="onArrowDown"
      @keydown.up="onArrowUp"
      @keydown.enter="onEnter"
      type="text"
    />
    <ul
      v-show="isOpen"
      class="autocomplete-results"
    >
      <li
        v-for="(result, i) in results"
        :key="i"
        @click="setResult(result)"
        class="autocomplete-result"
        :class="{ 'is-active': i === arrowCounter }"
      >
        {{ result }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'SearchAutocomplete',
  // ...
  data() {
    return {
      search: '',
      results: [],
      isOpen: false,
      arrowCounter: -1
    };
  },
  methods: {
    // ...
    handleClickOutside(event) {
      if (!this.$el.contains(event.target)) {
        this.arrowCounter = -1;
        this.isOpen = false;
      }
    },
    onArrowDown() {
      if (this.arrowCounter < this.results.length) {
        this.arrowCounter = this.arrowCounter + 1;
      }
    },
    onArrowUp() {
      if (this.arrowCounter > 0) {
        this.arrowCounter = this.arrowCounter - 1;
      }
    },
    onEnter() {
      this.search = this.results[this.arrowCounter];
      this.arrowCounter = -1;
      this.isOpen = false;
    }
  },
}
</script>

Let’s also add a visual aid by adding an active CSS class to the selected option:

src/components/SearchAutocomplete.vue
<style>
  // ...

  .autocomplete-result.is-active,
  .autocomplete-result:hover {
    background-color: #4AAE9B;
    color: white;
  }
</style>

Step 5 — Handling Async Loading

You can additionally provide async support by informing the component that it needs to wait for a response from the server to load the results.

Note: You could make the request inside the component, but most apps already use a specific lib to make requests, no need to add a dependency here.

You will need to make a couple of changes to the component:

  1. A pointer to inform whether we need to wait or not for the results
  2. Emit an event to the parent component once the value of the input changes
  3. A watcher to know when the data is received
  4. A loading indicator to inform the user
src/components/SearchAutocomplete.vue
<template>
  <div class="autocomplete">
    <input
      v-model="search"
      @input="onChange"
      @keydown.down="onArrowDown"
      @keydown.up="onArrowUp"
      @keydown.enter="onEnter"
      type="text"
    />
    <ul
      v-show="isOpen"
      class="autocomplete-results"
    >
      <li
        v-if="isLoading"
        class="loading"
      >
        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 }"
      >
        {{ result }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'SearchAutocomplete',
  props: {
    // ...
    isAsync: {
      type: Boolean
      required: false,
      default: false,
    },
  },
  // ...
  watch: {
    items: function (value, oldValue) {
      if (this.isAsync) {
        this.results = value;
        this.isOpen = true;
        this.isLoading = false;
      },
    }
  },
  // ...
  methods: {
    // ...
    onChange() {
      this.$emit('input', this.search);

      if (this.isAsync) {
        this.isLoading = true;
      } else {
        this.filterResults();
        this.isOpen = true;
      }
    },
    // ...
  },
}
</script>

This code change introduces an isAsync prop. If this is present in the component will filter the results by comparing items that are provided by a request from a server request.

Step 6 — Wrapping Up the Project

With all the changes applied, SearchAutocomplete.vue will resemble the following:

src/components/SearchAutocomplete.vue
<template>
  <div class="autocomplete">
    <input
      type="text"
      @input="onChange"
      v-model="search"
      @keydown.down="onArrowDown"
      @keydown.up="onArrowUp"
      @keydown.enter="onEnter"
    />
    <ul
      id="autocomplete-results"
      v-show="isOpen"
      class="autocomplete-results"
    >
      <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 }"
      >
        {{ result }}
      </li>
    </ul>
  </div>
</template>

<script>
  export default {
    name: 'SearchAutocomplete',
    props: {
      items: {
        type: Array,
        required: false,
        default: () => [],
      },
      isAsync: {
        type: Boolean,
        required: false,
        default: false,
      },
    },
    data() {
      return {
        isOpen: false,
        results: [],
        search: '',
        isLoading: false,
        arrowCounter: -1,
      };
    },
    watch: {
      items: function (value, oldValue) {
        if (value.length !== oldValue.length) {
          this.results = value;
          this.isLoading = false;
        }
      },
    },
    mounted() {
      document.addEventListener('click', this.handleClickOutside)
    },
    destroyed() {
      document.removeEventListener('click', this.handleClickOutside)
    },
    methods: {
      setResult(result) {
        this.search = result;
        this.isOpen = false;
      },
      filterResults() {
        this.results = this.items.filter((item) => {
          return item.toLowerCase().indexOf(this.search.toLowerCase()) > -1;
        });
      },
      onChange() {
        this.$emit('input', this.search);

        if (this.isAsync) {
          this.isLoading = true;
        } else {
          this.filterResults();
          this.isOpen = true;
        }
      },
      handleClickOutside(event) {
        if (!this.$el.contains(event.target)) {
          this.isOpen = false;
          this.arrowCounter = -1;
        }
      },
      onArrowDown() {
        if (this.arrowCounter < this.results.length) {
          this.arrowCounter = this.arrowCounter + 1;
        }
      },
      onArrowUp() {
        if (this.arrowCounter > 0) {
          this.arrowCounter = this.arrowCounter - 1;
        }
      },
      onEnter() {
        this.search = this.results[this.arrowCounter];
        this.isOpen = false;
        this.arrowCounter = -1;
      },
    },
  };
</script>

<style>
  .autocomplete {
    position: relative;
  }

  .autocomplete-results {
    padding: 0;
    margin: 0;
    border: 1px solid #eeeeee;
    height: 120px;
    overflow: auto;
  }

  .autocomplete-result {
    list-style: none;
    text-align: left;
    padding: 4px 2px;
    cursor: pointer;
  }

  .autocomplete-result.is-active,
  .autocomplete-result:hover {
    background-color: #4AAE9B;
    color: white;
  }
</style>

A live demo is available via CodePen.

Conclusion

In this article, you constructed an autocomplete component with Vue.js. This was achieved by utilizing v-model, filter, @input, @click, @keydown, and .$emit.

If you’d like to learn more about Vue.js, check out our Vue.js topic page for exercises and programming projects.

Creative Commons License