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.


This content originally appeared on DEV Community and was authored by Mads Stoumann

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>

In Chrome, this format is also supported:

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

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

[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

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);
}

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

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

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"

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;

The returned data, will be stored in:

let data = [];

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)
  }
}

We store a reference to the <datalist>:

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

… and add an eventListener on the input:

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:

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:

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:

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:

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:

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

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');

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>

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') { ... }
  })
}

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)

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

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

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:

autoSuggest(input, { apiCache: false });

— or with it's dataset:

autoSuggest(input, input.dataset);

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

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:

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

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');
  }
}

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

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

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>

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);
  }
})

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


This content originally appeared on DEV Community and was authored by Mads Stoumann


Print Share Comment Cite Upload Translate Updates
APA

Mads Stoumann | Sciencx (2021-08-07T06:42:43+00:00) Building A Minimal AutoSuggest. Retrieved from https://www.scien.cx/2021/08/07/building-a-minimal-autosuggest/

MLA
" » Building A Minimal AutoSuggest." Mads Stoumann | Sciencx - Saturday August 7, 2021, https://www.scien.cx/2021/08/07/building-a-minimal-autosuggest/
HARVARD
Mads Stoumann | Sciencx Saturday August 7, 2021 » Building A Minimal AutoSuggest., viewed ,<https://www.scien.cx/2021/08/07/building-a-minimal-autosuggest/>
VANCOUVER
Mads Stoumann | Sciencx - » Building A Minimal AutoSuggest. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2021/08/07/building-a-minimal-autosuggest/
CHICAGO
" » Building A Minimal AutoSuggest." Mads Stoumann | Sciencx - Accessed . https://www.scien.cx/2021/08/07/building-a-minimal-autosuggest/
IEEE
" » Building A Minimal AutoSuggest." Mads Stoumann | Sciencx [Online]. Available: https://www.scien.cx/2021/08/07/building-a-minimal-autosuggest/. [Accessed: ]
rf:citation
» Building A Minimal AutoSuggest | Mads Stoumann | Sciencx | https://www.scien.cx/2021/08/07/building-a-minimal-autosuggest/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.