DEV Community

Cover image for Building A Minimal AutoSuggest
Mads Stoumann
Mads Stoumann

Posted on

Building A Minimal AutoSuggest

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 post:

nXHX2


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 — 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>:

<datalist id="browsers">
  <option value="Edge">
  <option value="Firefox">
  <option value="Chrome">
  <option value="Opera">
  <option value="Safari">
</datalist>
Enter fullscreen mode Exit fullscreen mode

In Chrome, this format is also supported:

<option value="MSE">Microsoft Edge</option>
Enter fullscreen mode Exit fullscreen mode

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:

<label>
  <strong>Pick a browser</strong>
  <input
    autocomplete="off"
    autocorrect="off"
    list="browsers"
    spellcheck="false"
    type="search">
</label>
Enter fullscreen mode Exit fullscreen mode

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:

[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
}
Enter fullscreen mode Exit fullscreen mode

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

Reset Cross

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:

[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);
}
Enter fullscreen mode Exit fullscreen mode

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

}
[list]::-webkit-calendar-picker-indicator {
  display: none !important;
}
Enter fullscreen mode Exit fullscreen mode

There, much better:

Reset Cross Styled

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.

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

iphonedatalist

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 — 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:

data-api="//domain.com?q="
data-api-cache="0"
data-api-key="key"
min-length="3"
Enter fullscreen mode Exit fullscreen mode

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:

const cache = input.dataset.apiCache - 0 || 0;
Enter fullscreen mode Exit fullscreen mode

The returned data, will be stored in:

let data = [];
Enter fullscreen mode Exit fullscreen mode

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

export default function debounced(delay, fn) {
  let timerId;
  return function(...args) {
    if (timerId) clearTimeout(timerId);
    timerId = setTimeout(() => { fn(...args); timerId = null }, delay)
  }
}
Enter fullscreen mode Exit fullscreen mode

We store a reference to the <datalist>:

const list = document.getElementById(input.getAttribute('list'));
Enter fullscreen mode Exit fullscreen mode

… and add an eventListener on the input:

input.addEventListener('input', debounced(200, event => onentry(event)));
Enter fullscreen mode Exit fullscreen mode

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:

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('')
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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

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

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

input.addEventListener('autoSuggestSelect', event => { console.info(event.detail) });
Enter fullscreen mode Exit fullscreen mode

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:

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

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

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

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:

<datalist id="suggest"><option value=""></option></datalist>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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:

autoSuggest(input, args)
Enter fullscreen mode Exit fullscreen mode

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

const settings = Object.assign({
  api: '',
  apiCache: false,
  apiKey: ''
}, datasetToType(args));
Enter fullscreen mode Exit fullscreen mode

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

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;
}
Enter fullscreen mode Exit fullscreen mode

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

autoSuggest(input, { apiCache: false });
Enter fullscreen mode Exit fullscreen mode

— or with it's dataset:

autoSuggest(input, input.dataset);
Enter fullscreen mode Exit fullscreen mode

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

data-api-cache=":false"
Enter fullscreen mode Exit fullscreen mode

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:

invalid: 'Not a valid selection',
limit: false
Enter fullscreen mode Exit fullscreen mode

We'll add a new method:

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

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

input.addEventListener('search', () => input.value.length === 0 ? reset() : settings.limit ? limit() : '');
Enter fullscreen mode Exit fullscreen mode

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 is DAWA, a danish service for looking up addresses (try typing “park”):

<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>
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

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);
  }
})
Enter fullscreen mode Exit fullscreen mode

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, 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

Top comments (7)

Collapse
 
robertwagner profile image
Robert Wagner

this would be so cool! unfortunately it doesn't work in safari (mac os at least). you have to delete the last char of the input field for the datalist to show. tested with both DAWA and JSON placeholder input.

Collapse
 
madsstoumann profile image
Mads Stoumann

I just tested it in Safari, and get results. But you're right, there seems to be a problem ... a delay when you press backspace etc. I remember I tested it in Safari, when the article was written, so not sure what happened since.

Collapse
 
tarekali profile image
Tarek Ali

Such a great article. Would appreciate an article covering the “backend-side” of autosuggest; thinking about building a project involving spark with search functionality on top of it; not sure where to start. Thanks.

Collapse
 
heyrohit profile image
Rohit Gupta

I've heard of a node.js library called lunr :)

Collapse
 
madsstoumann profile image
Mads Stoumann

Thanks, Tarek! I've never developed an API, but would be interesting!

Collapse
 
jonrandy profile image
Jon Randy 🎖️

Indeed it took a while for datalist to be added to HTML, but it's been there for 10 years already. I'm always amazed when devs seem unaware of it

Collapse
 
madsstoumann profile image
Mads Stoumann

True, it's been in Chrome and Firefox for 10 years, but it wasn't supported in Safari until version 12.1, released in July 2019!