A Beautiful and Accessible Typeahead
When it comes to web UX, the little things make the biggest difference. How something looks is important, but how it feels and operates is, in our opinion, the more crucial and often overlooked aspect of any component. Does the user like actually using what you built?
Designing and building your own typeahead is a great exercise in crafting a user experience that both looks and feels good. It’s an example of something that when done right, seems simple and intuitive yet masks some fairly complex logic under the hood. Users nowadays have come to expect certain behavior from their components, and one of the cardinal rules of good UX is to craft something that feels familiar and natural. Let’s define a common typeahead and then define some common behavior the user might expect.
Designing and building your own typeahead is a great exercise in crafting a user experience that both looks and feels good.
A typeahead is, at its core, text input functioning as a search that displays results to the user as they type, rather than waiting for the full input or a separate search action like hitting enter or a click. The name suggests it will finish the user’s input for them, as Google might, but it can also do a preliminary search of a more confined dataset. Because of the common search experience we find all over the web now, a user might expect this type of behavior from a typeahead:
Typing only part of a desired result to see the result displayed
Instant results display
Being able to interact with the results
These are pretty obvious, but more considerations might slip under your radar:
The user should be able to tab through the results
The user should be able to use arrow keys to navigate the results
The user should be able to hit enter on a focused result as well as click it
Hiding and showing the results should be automatic and natural
Queries should be cached
Handling all of these can prove tricky. Typically, you would reach for a great out-of-the-box solution to handle this common UI component implementation. However, there are times when you can’t do that. We recently encountered a situation where we had chosen a particular library for handling the UI of a client application, and the library did not handle typeahead's very well. This meant rolling our own solution, which meant diving into the nuances of handling these small details. It was a great experience, and we are happy to share the journey with you!
First off, we needed to define what we needed from our typeahead. A simple typeahead might only require offering hints to a user about their input rather than full search results. It might only require one vector of data, such as the text itself, to perform an action with the selected result. This is the typical Google experience. Google offers text hints to your input and performs a full query with the selected text. For our case, however, we needed something a bit different. Our users would search within a smaller, well-defined dataset of objects for each input, such as a list of locations. Once the user selects a result, asynchronous actions take place with the selected object using its UUID. We, therefore, needed a solution that could handle the nice label of the result object and the identifier.
We needed a solution to handle the nice label of the result object and the identifier.
This put one solution to a typeahead off the table, the datalist element. For a simple typeahead, this would be a great solution. It’s native, has good browser support, supports keyboard navigation, and solves the intended purpose of offering users instant results for their search. However, it lacks some key features for a more complex need.
You cannot attach event listeners to a datalist element, which makes complex interaction almost impossible.
You cannot style it effectively, and the native element does not prefer user preferences for font, presenting an accessibility concern.
The intended purpose is to present hints or options to a user, not offer concrete data objects as a search result.
It will pull in unexpected results if the list is initially empty and the results seem based on your recent search history in that particular browser.
For us, the most limiting factor of the datalist was that you could not attach event listeners. A datalist works from a set of written options like standard select element options. They can have a value and a label. However, unlike a select, the datalist will show the value in the input field on selection, not the label. This would work fine for simple cases, but in our case, where a user might be selecting a result with a UUID as the value, seeing some input like “Example location” jump to “445045–3323” would be quite jarring. You could work around this with JavaScript… if you could attach event listeners!
For us, the most limiting factor of the datalist was that you could not attach event listeners.
After we tried datalist, we knew we had to reach for something simpler. It was time for some good ole div and ul elements with JavaScript magic. Let’s define the requirements for our typeahead component again:
User is shown instant results when they type
Users can navigate the results with a keyboard
An asynchronous action is performed with the result UUID on the selection
Capturing and performing a search with the results is pretty simple. Attach an event listener to the “input” event on the input field so we capture it as the user types into their keyboard. Perform an asynchronous request with fetch, $.ajax, or whatever library you prefer, and get the search results. A little gotcha here is to debounce the search, so you aren’t doing it on every new input, but once the user pauses for a fraction of a second. This saves requests on your end and prevents the user’s UI from refreshing too many times and distracting them while they type. You can check out more about how to debounce functions in this great article by David Walsh.
A little gotcha here is to debounce the search, so you aren’t doing it on every new input, but once the user pauses for a fraction of a second.
Ok, you have some results, but what do you do with them? The typical convention is to display them right underneath the text input, so that’s exactly what we did. Keep the user experience familiar. This could be done simply by resetting the inner HTML of an ul element and appending list items for every result.
<div>
<input type="text" id="searchInput" />
<ul class="results">
<!--...list items go here-->
</ul>
</div>
const createListItems = (results) => {
const container = document.getElementById('results');
const frag = document.createDocumentFragment();
container.innerHTML = '';
results.forEach(result => {
const result = createListItem(result);
frag.appendChild(result);
})
container.appendChild(frag);
}
const createListItem = (result) => {
//...create it
}
Remember, we want to react to click events on the result. So, in our createListItem function, we need to do something like this:
const item = document.createElement('li');
item.addEventListener('click', () => {
const id = result.uuid;
//... do something
});
This uses closures to capture the uuid of the result we passed. This lets us do something with the UUID while keeping it hidden from the user; they only see nice labels. You could also define and bind a function outside this one to the result.
const handleClick = (result) => {
//... do something with the specific result
}
item.addEventListener('click', handleClick.bind(null, result));
But wait! We also want to handle keyboard navigation. There are a couple of ways to approach this. We could keep creating only list items and give them a tab index:
item.tabIndex = 0; // Any non-negative integer will work here.
This makes the list of items tabbable. However, the recommended approach is to use a natively tabbable element, like a button. Considering we want this element to be interacted with, this is the more accessible solution. We will create a button within our list item creator and attach all events to that.
const createListItem = (result) => {
const listItem = document.createElement('li');
const button = document.createElement('button');
// do stuff, attach button, then return list item
}
Using a button also solves another problem automatically. If it is focused, the button will respond to the user hitting enter the same as a click. Hence, if a user tabs on a button and hits enter, our click handler will be called. Neat right?
If it is focused, the button will respond to the user hitting enter the same as a click.
Our next big task is to handle keyboard navigation aside from tabs, such as the up and down arrows. This means attaching an event listener to the parent of the input field. There are a couple of reasons for this:
If a user attempts to navigate the results with up or down arrows, they have probably just interacted with the input field.
If we put the result container next to the input field in the markup, we only need to attach the handler function on the parent to capture events from both the input field and the results container.
So, when a user is on the input field, they hit down, and we want to transition their focus from the input to the results seamlessly. This is simple:
<div id="parent">
<input type="text" id="searchInput" />
<ul class="results">
<!--...list items go here-->
</ul>
</div>
const parent = document.getElementById('parent');
const handleDown = () => {
const results = [...document.getElementById('results').children];
if (results.length) {
results[0].focus();
}
}
parent.addEventListener('keyup', e => {
if (e.key === "ArrowDown") {
handleDown();
}
})
This is a rather crude solution and doesn’t handle the case that there are multiple results and the user might want to scroll through the results. To do that, we need to track where the user is.
let index = -1;
const handleDown = () => {
const results = [...document.getElementById('results').children];
if (results.length && index < results.length - 1) {
index++;
results[index].focus();
}
}
This will move the user down the list. Implementing an up handler is much the same, except we need to place focus back on the text input if we go below 0:
const handleUp = () => {
const results = [...document.getElementById('results').children];
if (results.length && index > 0) {
index--;
results[index].focus();
} else {
document.getElementById('searchInput').focus();
}
}
This presents another gotcha though. What if the user clicks AWAY from the input and results? We don’t want to reset their input, but we probably want to hide the results and reset the index. Handling click-aways is tricky, and there isn’t a solution that always makes everyone happy. This is the solution we came up with: capture all click events within the typeahead and prevent them from bubbling, then capture all clicks on the document element and close the typeahead in the callback.
parent.addEventListener('click', e => {
e.stopPropagation();
})
document.addEventListener('click', e => {
// hide results list, reset index variable to -1
})
This approach is clean and keeps all clicks contained within the typeahead to itself, which is nice.
As for styling, since you use standard HTML elements, you can style and design however you want! Something to keep in mind is to keep the results list close to the input so the transition between focused elements is seamless.
This was our approach to building a typeahead from scratch. It is accessible, clean, and allows for complete control over style. Would you have done the same thing?
Tags:
- Code
- Tutorial