Building an Autocomplete Input Component in Ember

Last reviewed on January 08, 2022

Recently I learned that we can provide an autocomplete feature for <input> elements natively in the browser via the <datalist> tag.

I started playing around with it and made a reusable Glimmer component to search against any API with debouncing. If you're unfamiliar with debouncing, it is a technique where we only make the AJAX request once a user has stopped typing.

Try a demo of the autocomplete component in action.

The code for the demo can be found here: https://github.com/iamdtang/autocomplete-input.

Screenshot of demo

How to Use the AutocompleteInput Component

Now I'll describe how I implemented it. Let's start with the component invocation.

app/templates/github-repo-example.hbs
<label for="repo-search-input">
  Search GitHub Repositories
</label>

<AutocompleteInput
  id="repo-search-input"
  placeholder="Example: ember"
  @value={{this.repo}}
  @onInput={{this.setRepo}}
  @search={{this.searchRepos}}
  @options={{this.repos}}
/>

The AutocompleteInput can nearly be a drop-in replacement for an <input> element with the addition of 4 arguments.

  • @value is the value of the input that is bound to some tracked property
  • @onInput is a function that updates the value passed into @value. We're following Data Down, Actions Up (DDAU) here.
  • @options is an array of strings that is bound to some tracked property
  • @search is an asynchronous function that calls some API and updates the value passed into @options

Here is the backing class for the template above, which makes an AJAX request to the GitHub Repositories API.

app/controllers/github-repo-example.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class GithubRepoExampleController extends Controller {
  @tracked repos = [];

  @action
  async searchRepos(search) {
    const response = await fetch(
      `https://api.github.com/search/repositories?q=${search}`
    );

    const json = await response.json();

    this.repos = json.items.mapBy('full_name');
  }
}

The Implementation of the AutocompleteInput Component

Let's first look at the template.

app/components/autocomplete-input.hbs
<input
  list={{this.listId}}
  value={{@value}}
  ...attributes
  onInput={{perform this.onInputTask}}
/>

<datalist id={{this.listId}}>
  {{#each @options as |value|}}
    <option value={{value}} />
  {{/each}}
</datalist>

The value of the list attribute on the <input> needs to match the value of the id attribute on the <datalist>. This value is dynamically created using guidFor (see below) to ensure it is unique for every instance.

The onInput event is bound to this.onInputTask, which is an Ember Concurrency task with debouncing. This task will then call the @onInput argument. Because our component has an @onInput argument, we need to have ...attributes before onInput on the input. Otherwise, @onInput will take precedence and skip calling this.onInputTask.

Here is the backing class.

app/components/autocomplete-input.js
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
import { guidFor } from '@ember/object/internals';

export default class AutocompleteInputComponent extends Component {
  listId = guidFor(this);

  wasOptionSelected(value) {
    return this.args.options.includes(value);
  }

  @restartableTask
  *onInputTask(event) {
    const { value } = event.target;

    this.args.onInput(value);

    if (this.wasOptionSelected(value)) {
      return;
    }

    const debounce = 250;
    yield timeout(debounce);

    if (value) {
      this.args.search(value);
    }
  }
}

As mentioned above, I used Ember Concurrency, and specifically the restartableTask modifier on onInputTask to debounce the search as the user types into the input. The restartableTask modifier ensures that only one instance of a task is running by canceling any currently-running tasks and starting a new task instance immediately.

The onInputTask will then call the @search function (our async function that makes an AJAX request to the GitHub API) that we passed into the component with the user's search term.

A few other lines of code to point out:

app/components/autocomplete-input.js
if (this.wasOptionSelected(value)) {
  return;
}

The above lines are to ensure that when a user changes the input's value by selecting an option from the list of results, it doesn't trigger another AJAX request and open the menu of options again.

Conclusion

According to the Can I Use site, browser support for the <datalist> element is pretty good. Personally, I recommend using this over a custom solution if you need an autocomplete feature, as this is likely more accessible and way less complex.