Building an Autocomplete Component with Vue.js

Filipa Lacerda

Did you ever wonder how could you build an autocomplete component in Vue.js? Fear not! Thanks to some built-in Vue features it’s very simple. 🎉

In this article you’ll learn how to make a simple autocomplete component using v-model, event handling with key modifiers and how to prepare it for async requests.

HTML structure & CSS

The first thing we need is our HTML structure. In order to build an autocomplete component we’ll need at least two things: an input and a list. We’ll also add some default CSS in this iteration.

components/autocomplete.vue

<template>
  <div class="autocomplete">
    <input type="text" />
    <ul class="autocomplete-results">
      <li class="autocomplete-result">
      </li>
    </ul>
  </div>
</template>

<style>
  .autocomplete {
    position: relative;
    width: 130px;
  }

  .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:hover {
    background-color: #4AAE9B;
    color: white;
  }
</style>

Key events, v-model & showing the results

When the user types, we want to show them a list of results. We need to listen for input changes to know when to show those results.

In order to do so we’ll 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 we need to know when the user has finished typing to filter our results, we’ll add an event listener for @input.

components/autocomplete.vue

<script>
  export default {
    name: 'autocomplete',
    data() {
      return {
        search: '',
      };
    },
    methods: {
      onChange() {
        // ...
      }
    }
  };
</script>
<template>
  ...
  <input
    type="text"
    v-model="search"
    @input="onChange"
  />
  ...
</template>

Now that we know what to search for and when to do it, we need some data to show. For the purposes of this tutorial I’ll use a simple array, but you can update the filter function to handle more complex data structures.

We’ll filter an array of items for the text the user types. In the snippet below, you’ll notice that we need to lowercase both the typed text and each element of the array for a more accurate result.

We also need to make sure we only show our list of results after the user has typed something. We can do that through conditionally displaying it with the usage of v-show. The reason we are using v-show instead of v-if is because 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.

components/app.vue

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

components/autocomplete.vue

<script>
  export default {
    ...
    props: {
      items: {
        type: Array,
        required: false,
        default: () => [],
      },
    },
    data() {
      return {
        search: '',
        results: [],
        isOpen: false,
      };
    },
    methods: {
      onChange() {
        this.isOpen = true;
        this.filterResults();
      },
      filterResults() {
        this.results = this.items.filter(item => item.toLowerCase().indexOf(this.search.toLowerCase()) > -1);
      },
    },
  }
</script>
<template>
  ...
  <ul
    v-show="isOpen"
    class="autocomplete-results"
  >
    <li
      v-for="(result, i) in results"
      :key="i"
      class="autocomplete-result"
    >
      {{ result }}
    </li>
  </ul>
</template>

Now we need to make sure our list of results is usable. We want to be able to click on one of the results and automatically show that value as the chosen one. We also need to close the list of results once that happens for a better user experience.

We’ll do that by listening to the click event and setting the value as the search term.

<script>
  export default {
    ...
    methods: {
      setResult(result) {
        this.search = result;
        this.isOpen = false;
      },
    },
  }
</script>
<template>
  ...
  <li
    v-for="(result, i) in results"
    :key="i"
    @click="setResult(result)"
    class="autocomplete-result"
  >
    {{ result }}
  </li>
</template>

Async loading

We’ve built a very basic version of an autocomplete, but what if we need to make a request to the server to load the results?

Let’s add async support by informing the component that it needs to wait for the results.

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

We’ll need to make a couple of changes to our 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
<script>
  export default {
    props: {
      ...
      isAsync: {
        type: Boolean
        required: false,
        default: false,
      },
    },
    methods: {
      onChange() {
        // Let's warn the parent that a change was made
        this.$emit('input', this.search);

        // Is the data given by an outside ajax request?
        if (this.isAsync) {
          this.isLoading = true;
        } else {
          // Data is sync, we can search our flat array
          this.filterResults();
          this.isOpen = true;
        }
      },
    },
    watch: {
      // Once the items content changes, it means the parent component
      // provided the needed data
      items: function (value, oldValue) {
        // we want to make sure we only do this when it's an async request
        if (this.isAsync) {
          this.results = value;
          this.isOpen = true;
          this.isLoading = false;
        },
      }
    }
  }
</script>
<template>
  <ul
    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"
    >
      {{ result }}
    </li>
  </ul>
</template>

Support for arrow keys

Wouldn’t it be cool to use the arrow keys and hit enter?

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 we don’t need to verify which key code belongs to each key.

In order to track which result is being selected, we’ll add a prop to hold the value of a counter. We will set it’s initial value to -1, to guarantee no option is selected before the user actively selects one. We will increase it by 1 every time the user presses the arrow down key and decrease it by 1 when the user presses the arrow up key.

This way, when the user presses the enter key, we just need to get that index from the array of results.

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

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

<script>
  export default {
    data() {
      return {
        isOpen: false,
        results: [],
        search: '',
        isLoading: false,
        arrowCounter: -1,
      };
    },
    methods: {
      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>

<template>
  ...
  <input
    type="text"
    @input="onChange"
    v-model="search"
    @keyup.down="onArrowDown"
    @keyup.up="onArrowUp"
    @keyup.enter="onEnter"
  />
  <ul
    v-show="isOpen"
    class="autocomplete-results"
  >
    <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>
</template>
<style>
  .autocomplete-result.is-active,
  .autocomplete-result:hover {
    background-color: #4AAE9B;
    color: white;
  }
</style>

Uhuh! We have keyboard support! Only one thing is missing now, we need to close the list of results once the user clicks outside.

We’ll need to listen to a click event outside this component. Let’s do that once the component is mounted and when the user clicks somewhere we will need check if it was outside of our component. We will use Node.contains to check if the event target belongs to our component.

export default {
  methods: {
    handleClickOutside(evt) {
      if (!this.$el.contains(evt.target)) {
        this.isOpen = false;
        this.arrowCounter = -1;
      }
    }
  },
  mounted() {
    document.addEventListener('click', this.handleClickOutside);
  },
  destroyed() {
    document.removeEventListener('click', this.handleClickOutside);
  }
};

You can test this component in this codepen or check the entire source here:

<script>
  export default {
    name: 'autocomplete',

    props: {
      items: {
        type: Array,
        required: false,
        default: () => [],
      },
      isAsync: {
        type: Boolean,
        required: false,
        default: false,
      },
    },

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

    methods: {
      onChange() {
        // Let's warn the parent that a change was made
        this.$emit('input', this.);

        // Is the data given by an outside ajax request?
        if (this.isAsync) {
          this.isLoading = true;
        } else {
          // Let's  our flat array
          this.filterResults();
          this.isOpen = true;
        }
      },

      filterResults() {
        // first uncapitalize all the things
        this.results = this.items.filter((item) => {
          return item.toLowerCase().indexOf(this..toLowerCase()) > -1;
        });
      },
      setResult(result) {
        this.search = result;
        this.isOpen = false;
      },
      onArrowDown(evt) {
        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;
      },
      handleClickOutside(evt) {
        if (!this.$el.contains(evt.target)) {
          this.isOpen = false;
          this.arrowCounter = -1;
        }
      }
    },
    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>

<template>
  <div class="autocomplete">
    <input
      type="text"
      @input="onChange"
      v-model="search"
      @keyup.down="onArrowDown"
      @keyup.up="onArrowUp"
      @keyup.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>

<style>
  .autocomplete {
    position: relative;
  }

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

  .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>
  Tweet It
✖ Clear

🕵 Search Results

🔎 Searching...