Building an accessible autocomplete control by Adam Silver | Designer, London, UK.

This is an excerpt from my book, Form Design Patterns.

This article starts in the middle of chapter 3, A Flight Booking Form where I've been looking at ways to let users enter a destination country.

Unfortunately, native HTML form controls just aren't good enough for this type of interaction.

And so we need to build a custom autocomplete control from scratch.

A word of warning though: this is one of the hardest UI components I’ve ever had to make—they’re just way harder than they look.

An autocomplete control showing 3 suggestions with the second option highlighted.

An autocomplete control shows suggestions that match what the user types as they type.

Users can select a suggestion to complete their entry quickly and accurately or keep typing to further refine the suggested options.

The basic markup

To make it work when JavaScript is unavailable we need to start with a native form control that browsers provide for free.

As explained earlier, there are too many options for radio buttons; a search box is slow to use and can lead to zero results; and datalist is too buggy - which leaves us with the select box.

<div class="field"> <label for="destination"> <span class="field-label">Destination</span> </label> <select name="destination" id="destination"> <option value="">Select</option> <option value="1">France</option> <option value="2">Germany</option> <!-- … --> </select>

The enhanced markup

When JavaScript is available, our yet-to-be-written Autocomplete() constructor will enhance the basic HTML into this:

<div class="field"> <label for="destination"> <span class="field-label">Destination</span> </label> <select name="destination" aria-hidden="true" tabindex="-1" class="visually-hidden"> <!-- options here --> </select> <div class="autocomplete"> <input aria-owns="autocomplete-options--destination" autocapitalize="none" type="text" autocomplete="off" aria-autocomplete="list" role="combobox" id="destination" aria-expanded="false"> <svg focusable="false" version="1.1" xmlns=""> <!-- rest of SVG here --> </svg> <ul id="autocomplete-options--destination" role="listbox" class="hidden"> <li role="option" tabindex="-1" aria-selected="false" data-option-value="1" id="autocomplete_1"> France </li> <li role="option" tabindex="-1" aria-selected="true" data-option-value="2" id="autocomplete_2"> Germany </li> <!-- more options here --> </ul> <div aria-live="polite" role="status" class="visually-hidden"> 13 results available. </div> </div>

To hide the select box without stopping its value from being submitted to the server involves adding:

  • visually-hidden to hide it from sighted users
  • aria-hidden="true" to hide it from screen reader users
  • tabindex="-1" to stop keyboard users from being able to focus it

If you’re using all 3 attributes, it's normally better to use display: none because it achieves the same affect with cleaner code.

But this would stop the select box’s value from being submitted to the server. This is important because while the user won’t be interacting with the select box directly, its value is still submitted for processing.

Reassociating the label

The select box’s id attribute is transferred over to the text box because the label must be associated to it so it’s read out in screen readers, and to increase the hit area of the control as explained in chapter 1, “A Registration Form”.

The text box’s name attribute isn’t needed because its value isn’t sent to the server – it’s purely for interaction purposes and is used as a proxy to set the select box value behind the scenes.

Text box attribute notes

The role="combobox" attribute will ensure the input is announced as a combo box. A combo box is “an edit control with an associated list box that provides a set of predefined choices.”

The aria-autocomplete="list" attribute tells users that a list of options will appear. The aria-expanded attribute tells users whether the menu is expanded or collapsed by toggling its value between true and false.

The autocomplete="off" attribute stops browsers from showing their own suggestions, which would interfere with those offered by our component.

Finally, the autocapitalize="none" attribute stops browsers from automatically capitalizing the first letter. More on this in chapter 4, A Login Form.

The SVG icon is overlaid on the text box using CSS. The focusable="false" attribute fixes the issue that in Internet Explorer SVG elements are focusable by default.

The role="list" attribute is used to communicate the menu as a list, because it will be populated with a list of options. Each option has a role="option" attribute.

The aria-selected="true" attribute tells users which option within the list is selected or not by toggling the value between true and false.

The tabindex="-1" attribute means focus can be set to the option programmatically when users press certain keys. We’ll look at keyboard interaction later.

The data-option-value attribute stores the select box option value. When the user clicks an autocomplete option, the select box value is updated to keep them in sync. This ties the interface (what the user sees) with the select box value (what the user can’t see) that’s sent to the server.

Using a live region to make sure screen reader users know when options are suggested

Sighted users will see the suggestions appear in the menu as they type, but the act of populating the menu isn’t determinable to screen reader users without leaving the text box to explore the menu.

To provide a comparable experience (inclusive design principle 1), we’ll use a live region as explained in “A Checkout Form.”

As the menu is generated, the live region will be populated with how many results are available; for example, “13 results available.” With this information to hand, users can decide to keep typing to narrow the results or to select a suggestion from the menu.

As the feedback is only useful to screen reader users, it’s hidden using visually-hidden again.

Handling text input from the user

When the user types into the text box, we need to listen for certain keystrokes using JavaScript.

Autocomplete.prototype.createTextBox = function() { this.textBox.on('keyup', $.proxy(this, 'onTextBoxKeyUp'));
}; Autocomplete.prototype.onTextBoxKeyUp = function(e) { switch (e.keyCode) { case this.keys.esc: case this.keys.up: case this.keys.left: case this.keys.right: case case this.keys.enter: case case this.keys.shift: // ignore otherwise the menu will show break; case this.keys.down: this.onTextBoxDownPressed(e); break; default: this.onTextBoxType(e); }

The this.keys object is a collection of numbers that correspond to particular keys by their names. This is to avoid magic numbers, which makes the code easier to understand.

The switch statement filters out Escape, Up, Left, Right, Space, Enter, Tab, and Shift keys. If it didn’t, the default case would run and incorrectly show the menu.

Instead of filtering out the keys we aren’t concerned with, we could’ve specified the keys that we are concerned with. But this would mean specifying a huge range of keys, which would increase the chance of one being missed.

We’re mainly interested in the last two statements: when the user presses Down and the default case above which means everything else (a character, number, symbol, and so on). In this case the onTextBoxType() function will be called.

Autocomplete.prototype.onTextBoxType = function(e) { // only show options if user typed something if(this.textBox.val().trim().length > 0) { // get options based on value var options = this.getOptions(this.textBox.val().trim().toLowerCase()); // build the menu based on the options this.buildMenu(options); // show the menu this.showMenu(); // update the live region this.updateStatus(options.length); } // update the select box value which // the server uses to process the data this.updateSelectBox();

The getOptions() method (covered later) filters the options based on what the user typed.

Composite controls should have a single tab stop

The autocomplete control is a composite control which means it has different interactive and focusable parts. For example, users type in the text box and they move to the menu to select a suggestion.

Composite components should only have one tab stop like the WAI-ARIA Authoring Practices 1.1 specification says:

A primary keyboard navigation convention common across all platforms is that the tab and shift+tab keys move focus from one UI component to another while other keys, primarily the arrow keys, move focus inside of components that include multiple focusable elements. The path that the focus follows when pressing the tab key is known as the tab sequence or tab ring.

A set of radio buttons is a composite control too.

Once the first radio button is focused, users can use the arrow keys to move between each option. Pressing Tab will move focus to the next focusable control in the tab sequence.

Back to the autocomplete.

The text box is naturally focusable by the Tab key. Once focused, the user will be able to press the arrow keys to traverse the menu, which we’ll look at shortly.

Pressing Tab when the text box or menu option is focused should hide the menu to stop it from obscuring the content beneath when not in use. We’ll look at how to do this shortly.

ARIA activedescendant doesn’t work for an autocomplete control

A lot of autocompletes use the aria-activedescendant attribute as an alternative way to make sure there’s just one tab stop.

It works by keeping focus on the component’s container at all times and referencing the currently active element.

But this doesn’t work for an autocomplete control because the text box is a sibling—not a parent—of the menu.

The onblur event is triggered when the user leaves an in-focus element. In the case of the autocomplete, we could listen to this event on the text box.

The virtue of using the onblur event is that it will be triggered when the user leaves the field by pressing Tab and by clicking or tapping outside the element.

this.textBox.on('blur', function(e) { // hide menu });

Unfortunately, the act of moving focus programmatically to the menu triggers the blur event, which will hide the menu. This makes the menu inaccessible to keyboard users.

One solution involves using setTimeout(), which lets us put a delay on the event. The delay gives us time to cancel the event using clearTimeout() should the user move focus to the menu within that time.

This stops the menu being hidden making it accessible again.

this.textBox.on('blur', $.proxy(function(e) { // set a delay before hiding the menu this.timeout = window.setTimeout(function() { // hide menu }, 100);
}, this));'focus', $.proxy(function(e) { // cancel the hiding of the menu window.clearTimeout(this.timeout);
}, this));

But this doesn’t work because there’s a problem with the blur event in iOS 10. It incorrectly triggers the blur event on the text box when the user hides the on-screen keyboard. This stops users from accessing the menu altogether.

The actual solution is next.

Instead of hiding the menu using the blur event, we can use the keydown event to listen out for when the user presses the Tab key.

this.textBox.on('keydown', $.proxy(function(e) { switch (e.keyCode) { case // hide menu break; }
}, this));

But unlike the blur event, this solution doesn’t cover the case where users blur the control by clicking outside of it.

So we'll cover that by listening to the document’s click event and making sure we only hide the menu if the user clicks outside of the control.

$(document).on('click', $.proxy(function(e) { if(!this.container[0].contains( { // hide the menu }
}, this));

When the text box is focused, pressing Down triggers onTextBoxDownPressed().

Autocomplete.prototype.onTextBoxDownPressed = function(e) { var option; var options; var value = this.textBox.val().trim(); /* When the value is empty or if it exactly matches an option show the entire menu */ if(value.length === 0 || this.isExactMatch(value)) { // get options based on the value options = this.getAllOptions(); // build the menu based on the options this.buildMenu(options); // show the menu this.showMenu(); // retrieve the first option in the menu option = this.getFirstOption(); // highlight the first option this.highlightOption(option); /* When there’s a value that doesn’t have an exact match show the matching options */ } else { // get options based on the value options = this.getOptions(value); // if there are options if(options.length > 0) { // build the menu based on the options this.buildMenu(options); // show the menu this.showMenu(); // retrieve the first option in the menu option = this.getFirstOption(); // highlight the first option this.highlightOption(option); } }

If the user presses Down without having typed anything, the menu will show all options and then focus the first one.

The same thing will happen if the user types an exact match. This will be rare because most users who spot the suggestions will select one as it’s quicker.

The else condition will populate options that match (if any), and then focus the first one. At the end of both scenarios highlightOption() is called, which we’ll look at shortly.

The menu may contain hundreds of options. To ensure the menu items are visible, we’ll use the following styles.

.autocomplete [role=listbox] { max-height: 12em; overflow-y: scroll; -webkit-overflow-scrolling: touch;

The max-height property lets the menu grow to a maximum height. Once the content inside the menu surpasses that height, users can scroll the menu thanks to the overflow-y: scroll property.

The last, non-standard property enables momentum scrolling on iOS. This ensures the autocomplete control scrolls the same way as it would everywhere else.

Selecting an option

We’ll use event delegation to listen to when the user clicks an option which is more efficient than adding a click event to each option.

Autocomplete.prototype.createMenu = function() {'click', '[role=option]', $.proxy(this, 'onOptionClick'));
}; Autocomplete.prototype.onOptionClick = function(e) { var option = $(e.currentTarget); this.selectOption(option);

The event handler retrieves the option (e.currentTarget) and hands it off to selectOption().

Autocomplete.prototype.selectOption = function(option) { var value = option.attr('data-option-value'); this.setValue(value); this.hideMenu(); this.focusTextBox();

The selectOption() method takes the option to be selected and extracts the value of the data-option-value attribute. It’s then passed to setValue() which populates the text box and hidden select box. Finally, the menu is hidden and the textbox is focused.

This same routine is performed when the user selects an option with the Space or Enter keys.

Once focus is within the menu, we can let users traverse the menu with the keyboard by listening to the keydown event.

Autocomplete.prototype.createMenu = function() {'keydown', $.proxy(this, 'onMenuKeyDown'));
}; Autocomplete.prototype.onMenuKeyDown = function(e) { switch (e.keyCode) { case this.keys.up: // Do stuff break; case this.keys.down: // Do stuff break; case this.keys.enter: // Do stuff break; case // Do stuff break; case this.keys.esc: // Do stuff break; case // Do stuff break; default: this.textBox.focus(); }
Key Action
Up If the first option is focused, set focus to the text box. Otherwise set focus to the previous option.
Down Focus the next menu option. If it’s the last menu option, do nothing.
Tab Hide the menu.
Enter or Space Select the currently highlighted option and focus the text box.
Escape Hide the menu and focus the text box.
Everything else Focus the text box (so users can continue typing).

Highlighting the focused options

When the user focuses an option by pressing the Up or Down keys, highlightOption() is called.

Autocomplete.prototype.highlightOption = function(option) { // if there’s a currently selected option if(this.activeOptionId) { // get the option var activeOption = this.getOptionById(this.activeOptionId); // unselect the option activeOption.attr('aria-selected', 'false'); } // set new option to selected option.attr('aria-selected', 'true'); // If the option isn’t visible within the menu if(!this.isElementVisible(option.parent(), option)) { // make it visible by setting its position inside the menu option.parent().scrollTop(option.parent().scrollTop() + option.position().top); } // store new option for next time this.activeOptionId = option[0].id; // focus the option option.focus();

The method performs several tasks.

First, it checks to see if there’s a previously active option. If so, the aria-selected attribute is set to false, which ensures the state is communicated to screen reader users.

Second, the new option’s aria-selected attribute is set to true.

As the menu has a fixed height, the newly focused option could be out of the menu’s visible area. So we check whether this is the case using isElementVisible().

If it’s not visible, the menu’s scroll position is adjusted using scrollTop(), which makes sure it’s in view.

Next, the new option is stored so that it can be referenced later when the method is called again for a different option. And finally, the option is focused to ensure its value is announced in screen readers.

To provide feedback to sighted users we can use the same [aria-selected=true] CSS attribute selector like this:

.autocomplete [role=option][aria-selected="true"] { background-color: #005EA5; border-color: #005EA5; color: #ffffff;

Tying state and style together is good because it ensures that state changes are communicated interoperably. Form should follow function, and doing so directly keeps them in-sync.

Filtering the options

A good filter forgives small typos and mixed letter casing.

A quick recap: remember that the data driving the suggestions reside in the <option>s.

<select> <option value="">Select</option> <option value="1">France</option> <option value="2">Germany</option>

As noted above, getOptions() is called when we need to populate the menu with matching options.

Autocomplete.prototype.getOptions = function(value) { var matches = []; // Loop through each of the option elements'option').each(function(i, el) { el = $(el); // if the option has a value and the option’s text node matches the user-typed value if(el.val().trim().length > 0 && el.text().toLowerCase().indexOf(value.toLowerCase()) > -1) { // push an object representation to the matches array matches.push({ text: el.text(), value: el.val() }); } }); return matches;

The method takes the user-entered value as a parameter. It then loops through each of the <option>s and compares the value to the option’s text content (the bit inside the element).

It does so by using indexOf() which checks to see if it contains an occurence of the specified value. This means users can type incomplete parts of countries and still have relevant suggestions shown to them.

The value is trimmed and converted to lowercase, which means options will still be shown if the user has, for example, turned on caps lock. Users shouldn’t have to fix problems we can fix for them automatically.

Each matched option is added to the matches array, which will be used by the calling function to populate the menu.

Supporting endonyms and typos

An endonym is a name used by the people from a particular area of that area (or themselves or their language).

For example, Germany in German is “Deutschland.” We can follow inclusive design principle 5, “Offer choice,” by letting users type an endonym.

To do this, we first need to store it somewhere. We can put the endonym inside a data attribute on the <option> element.

<select> <!-- more options --> <option value="2" data-alt="Deutschland">Germany</option> <!-- more options -->

Now we can change the filter function to check the alternative value like this:

Autocomplete.prototype.getOptions = function(value) { var matches = []; // Loop through each of the option elements'option').each(function(i, el) { el = $(el); // if the option has a value and the option’s text node matches the user-typed value or the option’s data-alt attribute matches the user-typed value if( el.val().trim().length > 0 && el.text().toLowerCase().indexOf(value.toLowerCase()) > -1 || el.attr('data-alt') && el.attr('data-alt').toLowerCase().indexOf(value.toLowerCase()) > -1 ) { // push an object representation to the matches array matches.push({ text: el.text(), value: el.val() }); } }); return matches;

You can use the same attribute to store common typos if you like.


And that’s it. Here's a demo.