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:
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
orEnter
, 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:
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:
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:
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
Top comments (7)
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.
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.
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.
I've heard of a node.js library called lunr :)
Thanks, Tarek! I've never developed an API, but would be interesting!
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 itTrue, 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!