Building A Minimal AutoSuggest
Tags: html, css, javascript, webdev
Published: 2021-08-07 06:42:43

It took the Web a lot of years to introduce the `<datalist>`-tag, essential in creating one of the most widely used UI-components: the “AutoSuggest”. In this tutorial we'll be building a minimal “AutoSuggest”, both *with* and *without* JavaScript.

---

In one of the first books I read about UI-design, “The Windows Interface Guidelines for Software Design” from 1995, it was called *a Combobox* — because it's a *combination* of a *drop-down list* and a *text-input*. I personally think that term makes more sense than “AutoSuggest” or “Type Ahead”, but it seems the world has chosen “AutoSuggest” — so let's stick with that!

jQueryUI has the “AutoComplete”-plugin, incorrectly named, as “autocomplete” is a slightly different thing, as seen in this image from a [UX Stackexchange](https://ux.stackexchange.com/questions/40104/how-to-provide-autocomplete-and-autosuggest-on-the-same-search-box-at-the-same-t) post:

![nXHX2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/04e3trgwhp84fo75avd3.jpg)

---
## Basic Structure

In most of the examples you'll see online, a `<datalist>` is used with the `<input type="text">`. I prefer to use `<input type="search">`. Why? Because this type adds some nice, extra, accessibility-friendly features out-of-the-box:

- The `Escape`-key clears the list-selection, a second press clears the input altogether.

- In Chrome and Safari, an event — [onsearch](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/search_event) — is triggered when you press `Escape` or `Enter`, or when you click the little “reset cross”.

---
## The markup
The suggestions themselves are `<option>`s in a `<datalist>`:

```html
<datalist id="browsers">
  <option value="Edge">
  <option value="Firefox">
  <option value="Chrome">
  <option value="Opera">
  <option value="Safari">
</datalist>
```

In Chrome, this format is also supported:
```html
<option value="MSE">Microsoft Edge</option>
```

Both `value` and `innerText` will show up in the list, but only `value` will be inserted, when you select an item.

To link a `<datalist>` with an input, just take the `id` and use as a `list`-attribute:

```html
<label>
  <strong>Pick a browser</strong>
  <input
    autocomplete="off"
    autocorrect="off"
    list="browsers"
    spellcheck="false"
    type="search">
</label>
```

We don't want `autocomplete` or `spellcheck` to interfere, so we set them to `off` and `false`. `autocorrect` is a Safari-only property, that should also be disabled in this case.  

---

## The CSS
Not much here. We can use `-webkit-appearance: none` to clear the default browser-styling, and add our own. Here's an example:

```css
[type="search"] {
  border: 1px solid #AAA;
  font-size: 1rem;
  margin-block: 0.5rem;
  min-inline-size: 20rem;
  padding: 0.5rem 0.75rem;
  -webkit-appearance: none
}
```

What you probably **do** want to change, is that little “cross-icon”, that resets the input:

![Reset Cross](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s67cgoo2ijye0docdh21.png)

I use a SVG-icon in a `url()`, that I store in a *CSS Custom Property*, so it can be used as both a `mask-image` and a `-webkit-mask-image` for browser-compatibility:

```css
[type="search"]::-webkit-search-cancel-button {
  --reset: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17.016 15.609l-3.609-3.609 3.609-3.609-1.406-1.406-3.609 3.609-3.609-3.609-1.406 1.406 3.609 3.609-3.609 3.609 1.406 1.406 3.609-3.609 3.609 3.609zM12 2.016q4.125 0 7.055 2.93t2.93 7.055-2.93 7.055-7.055 2.93-7.055-2.93-2.93-7.055 2.93-7.055 7.055-2.93z"/></svg>');
  background-color: currentColor;
  display: block;
  height: 1rem;
  mask-image: var(--reset);
  width: 1rem;
  -webkit-appearance: none;
  -webkit-mask-image: var(--reset);
}
```

Chrome adds a drop-down-arrow to an `<input>` with a `<datalist>`, which we can hide:

```css
}
[list]::-webkit-calendar-picker-indicator {
  display: none !important;
}
```

There, much better:

![Reset Cross Styled](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/73jaxeyi90441ekte26v.png)

On mobile devices, the `<input type="search">` will trigger a virtual keyboard with a “Search”-button. If you **don't** want that, look into [inputmode](https://css-tricks.com/everything-you-ever-wanted-to-know-about-inputmode/).

On an iPhone, a `<datalist>` is displayed like this:

![iphonedatalist](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3q9kdeesayyis88sjonn.png) 

Far from perfect, but still much better than many custom solutions, where the virtual keyboard makes the “AutoSuggest” jump up and down!

---

That's the minimalistic, JavaScript-free *AutoSuggest!*

Excellent for things like a [country selector](https://madsstoumann.github.io/autosuggest/countries.html) — and *much better* than the minified **224kb** jQueryUI's “AutoComplete”-plugin consumes (including it's CSS, and jQuery itself).

But what if you want to use an API, creating `<option>`s dynamically?

---

## Adding an API

Before we look at the JavaScript, let's add some extra attributes to the `<input type="search">`-markup:

```html
data-api="//domain.com?q="
data-api-cache="0"
data-api-key="key"
min-length="3"
```
 
The `data-api` is for the `url` we want to `fetch()`.  
The search-text will be appended to this.

The `data-api-cache` can either be `0` (disabled) or `1` (enabled). If enabled, the `<datalist>`-options will not be overwritten after the initial `fetch()`, and as you type in more text, the native browser-filtering of a `<datalist>` will be used.

`data-api-key` is the “key / property” in the result-objects, you want to search and display as `<option>`s.

`min-length` is a standard-attribute. In this case, it indicates how many characters you need to type, before the `fetch()` is triggered.

---

## JavaScript
For the JavaScript, I'm going to explain all the methods I'm using, so you can build your own, customized *AutoSuggest* with just the features you need.

First, we add a function, `autoSuggest(input)` with a single parameter: the `input`.

Next, a boolean indicating whether cache should be used:
```js
const cache = input.dataset.apiCache - 0 || 0;
```

The returned data, will be stored in:
```js
let data = [];
```

In order not to crash the service, we're calling, we need a *debounce*-method to filter out events:

```js
export default function debounced(delay, fn) {
  let timerId;
  return function(...args) {
    if (timerId) clearTimeout(timerId);
    timerId = setTimeout(() => { fn(...args); timerId = null }, delay)
  }
}
```

We store a reference to the `<datalist>`:

```js
const list = document.getElementById(input.getAttribute('list'));
```

… and add an `eventListener` on the `input`:
```js
input.addEventListener('input', debounced(200, event => onentry(event)));
```

The `200` is the delay used in the `debounce`-method. You can modify this, or add it to a settings-object or similar.

Finally, there's the `onentry`-method called from within the `debounce`:

```js
const onentry = async function(event) {
  const value = input.value.length >= input.minLength && input.value.toLowerCase();
  if (!value) return;
  if (!data.length || cache === false) {
    data = await (await fetch(input.dataset.api + encodeURIComponent(value))).json();
    list.innerHTML = data.map(obj => `<option value="${obj[input.dataset.apiKey]}">`).join('')
  }
}
```

It's an async function, that first checks whether the input has the minimal amount of characters. If not, it simply returns.

If no data exists already, or if the cache is set to `0: false`, a `fetch()` is triggered, and the `<option>`s are updated.

Cool, we now have dynamic options, and a minified script, that's just **497 bytes**, approx. **349 bytes gzipped!**

But I think it lacks a few features. I want to trigger a *Custom Event*, when I select an option from the list, and I want the *object* from the matching search-result in that event.

Let's modify the `onentry`-method a bit. We can use the `event.inputType` to detect, when the user clicks on a list item, or selects it using `Enter`:

```js
if (event.inputType == "insertReplacementText" || event.inputType == null) {
  const option = selected(); 
  if (option) input.dispatchEvent(new CustomEvent('autoSuggestSelect', { detail: JSON.parse(option.dataset.obj) }));
  return;
}
```

The `selected`-method looks up and returns the current input-text in the array of objects:

```js
const selected = () => {
  const option = [...list.options].filter(entry => entry.value === input.value);
  return option.length === 1 ? option[0] : 0;
}
```

Now — in another script! — we can listen for that event:

```js
input.addEventListener('autoSuggestSelect', event => { console.info(event.detail) });
```

What if we want to *reset* the list? In Safari and Chrome, there's the `onsearch`-event, that's triggered on both reset and `Enter`.
Let's add a `reset()`-method:

```js
const reset = () => { data = []; list.innerHTML = `<option value="">` }
```

And trigger it, when a user clicks the “reset-cross” or presses `Escape`:

```js
input.addEventListener('search', () => input.value.length === 0 ? reset() : '// Do something on Enter');
```

The blank `<option>` in the `reset()`-method is a hack for Firefox and Safari, that otherwise has some issues with a dynamic `<datalist>`. It can therefore be a good idea to add an empty option by default in the markup: 


```html
<datalist id="suggest"><option value=""></option></datalist>
```

The script is now **544 bytes** gzipped. Is there anything else, we can do?


In Firefox, we can add a small “polyfill” for `onsearch`:

```js
if (!('onsearch' in input)) {
  input.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') { input.value = ''; reset(); }
    if (event.key === 'Enter') { ... }
  })
}
```

---

## What Else?
You can continue to add stuff yourself. But before you do that, let's add a `settings`-object to hold the configuration parameters for what we already have — and whatever you want to add! First, we'll change the main function:

```js
autoSuggest(input, args)
```

Then, we'll merge the `args` into a settings-object:

```js
const settings = Object.assign({
  api: '',
  apiCache: false,
  apiKey: ''
}, datasetToType(args));
```

The `datasetToType` is a small helper-function, that'll convert dataset-entries to correct types (non-string values prefixed with a `:`):

```js
export default function datasetToType(obj) {
  const object = Object.assign({}, obj);
  Object.keys(object).forEach(key => {
    if (typeof object[key] === 'string' && object[key].charAt(0) === ':') {
      object[key] = JSON.parse(object[key].slice(1));
    }
  });
  return object;
}
```

This way, we can call the `autoSuggest`-method with *either* a standard JavaScript-object:

```js
autoSuggest(input, { apiCache: false });
```

— or with it's `dataset`:

```js
autoSuggest(input, input.dataset);
```

In the markup, we'll replace the `0`'s with `:false` and the `1`'s with `:true`:

```html
data-api-cache=":false"
```

We also need to replace `input.dataset.api` with `settings.api`, remove the `cache` constant, and replace it with `settings.cache` (and various other places, check the final example!), but we now have a `settings`-object, we can extend with new features.

---

## Limiting choices
Do you want to limit the `value` to **only** allow values from the list? Let's extend the `settings`-object:

```js
invalid: 'Not a valid selection',
limit: false
```

We'll add a new method:

```js
const limit = () => {
  const option = selected();
  input.setCustomValidity(option ? '' : settings.invalid);
  if (!input.checkValidity()) {
    input.reportValidity();
    console.log('invalid');
  }
  else {
    console.log('valid');
  }
}
```

And finally, we'll update the `onsearch`-event:

```js
input.addEventListener('search', () => input.value.length === 0 ? reset() : settings.limit ? limit() : '');
```

This method uses HTML5's default validation api — and currently does *nothing* (apart from logging to the `console`!). You can/should tweak it, to use your own way of handling invalid state.

---

## Examples
The first [example](https://madsstoumann.github.io/autosuggest/) is DAWA, a danish service for looking up addresses *(try typing “park”):*


```html
<label>
  <strong>DAWA - Danish Address Lookup</strong>
  <input
    autocomplete="off"
    autocorrect="off"
    data-api="//dawa.aws.dk/adresser/autocomplete?side=1&per_side=10&q="
    data-api-cache=":false"
    data-api-key="tekst"
    data-limit=":true"
    list="dawa"
    minlength="3"
    spellcheck="false"
    type="search">
</label>
<datalist id="dawa"><option value=""></option></datalist>
```

Below that is JSON placeholder *(try typing “lorem”):*

```html
<label>
  <strong>JSON placeholder</strong>
  <input
    autocomplete="off"
    autocorrect="off"
    data-api="//jsonplaceholder.typicode.com/albums/?_limit=10&q="
    data-api-key="title"
    list="jsonplaceholder"
    minlength="3"
    spellcheck="false"
    type="search">
</label>
<datalist id="jsonplaceholder"><option value=""></option></datalist>
```

A quick way to run the `autoSuggest`-method on all elements with an associated `<datalist>` is:


```js
import autoSuggest from './autosuggest.mjs';
const inputs = document.querySelectorAll('[list]');
inputs.forEach(input => {
  if (input.dataset.api) {
    input.addEventListener('autoSuggestSelect', event => { console.info(event.detail) });
    autoSuggest(input, input.dataset);
  }
})
```

---

## Conclusion
This is not meant to be a tried and tested “AutoSuggest”, you can use “as-is” in a project. It's more a set of principles and ideas, so you can go ahead and make your own, customizing it to your needs: minimal or bloated with features!

More importantly, it's meant to show how a “native first”-approach, using built-in tags and their built-in functionality, can often result in much less JavaScript and less overhead.

I've made a [repository](https://github.com/madsstoumann/autosuggest), from where you can grab the demo-files. Open the folder in *VS Code*, and start it with *Live Server* or similar. [Live demo here](https://madsstoumann.github.io/autosuggest/)