Day 24: Making autocomplete search accessible for React apps with Downshift

masakudamatsu

Masa Kudamatsu

Posted on February 7, 2023

Day 24: Making autocomplete search accessible for React apps with Downshift

TL;DR

To equip your React app with accessible autocomplete search, use the useCombobox hook from the Downshift library. It makes web developers’ life a lot easier than coding from scratch. This article reviews the HTML code for accessible autocomplete search (Section 2) and then describes how we can use Downshift to generate it (Section 3).

Initially, only a search box is shown with the text cursor flashing in it. Then the letter "s" appears in the search box, and five autocomplete suggestions are revealed below the search box: San Francisco, Sapporo, Seoul, Singapore, and Stockholm. Each is alternately highlighted in sequence. Finally, the word "Stockholm" appears in the search box while the five suggestions disappear.
The user experience this article aims to implement when the user (1) types "s", (2) presses the Down Arrow key five times, and (3) hits the Return key.

1. Introduction

My Ideal Map, a web app I’m building, relies on Google Maps’s autocomplete search: whenever the user types a character in the search box, the app suggests the list of place names that the user may be looking for.

And I want to implement the autocomplete search feature with accessibility in mind and by using React. Learning about how to make autocomplete search accessible, however, I find it daunting to code from scratch. So it is worth exploring the possibility of using an external library that is battle-tested enough to minimize the number of bugs.

Downshift, a library that I learned about while I was watching video tutorials from Epic React, fits the perfect bill. It does all the tedious jobs for accessibility behind the scene. Plus, it is easy to customize for my specific needs, with the customized code easy to maintain.

In this article, I explain how I use Downshift to equip my React app with accessible autocomplete search.

2. HTML for accessibility

Before getting into the detail of Downshift, let’s review what the HTML code should look like for accessible autocomplete search.

Browsing ARIA Authoring Practices Guide, I’ve figured out that the example entitled "Editable Combobox With List Autocomplete" is the closest to what we know as autocomplete search.

Here is what I have learned:

2.1 Default

Before the user enters text in a search box, the HTML code should be something like this:

<label for="cb1-input">
  Search a place on the map
</label>
<input
  aria-autocomplete="list"
  aria-controls="cb1-listbox"
  aria-expanded="false"
  id="cb1-input"
  role="combobox"
  type="search"
/>
<ul
  id="cb1-listbox"
  role="listbox"
  aria-label="Autocomplete suggestions"
>
</ul>
Enter fullscreen mode Exit fullscreen mode

It is the standard practice to link up the <label> element’s for attribute with the <input> element’s id attribute (so the screen reader user can tell what the <input> element is for). And with the type="search" attribute, iOS and Android change the mobile keyboard’s Return key label into “go” and the magnifying glass icon, respectively (see Alex Holacheck’s little demo).

Specific to autocomplete search is the combination of role="combobox" and aria-autocomplete="list". These two attributes together tell the screen reader user that this search box (i.e., <input type="search"/>) will pop up the list of autocomplete suggestions once the user enters text in it.

But the screen reader user needs to know where the popup list is. The aria-controls attribute does this job, by referring to the id attribute of the popup list, typically the <ul> element as shown in this example.

The aria-expanded="false" tells the screen reader user that the popup list is currently hidden.

The <ul> element has the role="listbox" attribute, which tells the screen reader user that it serves as the popup list of autocomplete suggestions.

The ARIA Authoring Practices Guide isn’t explicit about whether the listbox element should be in the DOM by default or not. However, the Downshift’s documentation briefly mentions that it should for accessibility reasons. So I follow this advice.

2.2 Once search text is entered

Once the user enters text in the search box, the HTML code should change into somethinig like this:

<label for="cb1-input">
  Search a place on the map
</label>
<input 
  id="cb1-input"
  type="search"
  role="combobox"
  aria-autocomplete="list"
  aria-controls="cb1-listbox"
  aria-expanded="true"        // CHANGED! 
/> 
<ul
  id="cb1-listbox"
  role="listbox"
  aria-label="Autocomplete suggestions"
>
  { /* ADDED FROM HERE */}
  <li id="lb1" role="option">Suggestion 1</li>
  <li id="lb2" role="option">Suggestion 2</li>
  <li id="lb3" role="option">Suggestion 3</li>
  <li id="lb4" role="option">Suggestion 4</li>
  <li id="lb5" role="option">Suggestion 5</li>
  { /* ADDED UNTIL HERE */}
</ul>
Enter fullscreen mode Exit fullscreen mode

The <input> element’s aria-expanded attribute turns into true, as the popup list of autocomplete suggestions is now shown.

The role="option" in each <li> element tells the screen reader user that it represents one of the autocomplete suggestions.

2.3 When the keyboard user selects an autocomplete suggestion

Now, suppose the keyboard user (which of course includes the screen reader user) selects “Suggestion 1” with the Down Arrow key. This should transform the HTML code into something like this:

<label for="cb1-input">
  Search a place on the map
</label>
<input
  id="cb1-input"
  type="search"
  role="combobox"
  aria-autocomplete="list"
  aria-controls="cb1-listbox"
  aria-expanded="true"
  aria-activedescendant="lb1"      // CHANGED!
/>
  <ul
    id="cb1-listbox"
    role="listbox"
    aria-label="Autocomplete suggestions"
  >
    <li id="lb1" role="option"
      aria-selected="true"         // CHANGED!
    >
      Suggestion 1
    </li>
    <li id="lb2" role="option">
      Suggestion 2
    </li>
    <li id="lb3" role="option">
      Suggestion 3
    </li>
    <li id="lb4" role="option">
      Suggestion 4
    </li>
    <li id="lb5" role="option">
      Suggestion 5
    </li>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

The <input type="search"> now has an additional attribute aria-activedescendant, which refers to the id attribute of the selected <li> element.

The selected <li> element has an additional attribute aria-selected="true", to indicate that it is selected by the user.

By hitting the Return key, then, the selected item is chosen by the user.

2.4 Implementing with React

How can we implement with React the above-described changes of HTML code for autocomplete search? Doing it from scratch appears to be a daunting task.

Luckily, I’ve found a rescue: Downshift, a library written by Kent C Dodds and others for React apps.

3. Downshift

Downshift, by default, is designed to create the user experience where clicking the text field will reveal autocomplete suggestions. This behavior is ideal for the user to choose a country of residence from the predefined list, for example.

But it is slightly different from what I want to implement with the place search of Google Maps API. I want typing the first character of search text to reveal autocomplete suggestions.

So I need to tweak a bit the code snippet provided in Downshift’s documentation. Here’s how.

3.1 Setting up

Let’s first install the library with NPM: type the following in Terminal:

npm install downshift
Enter fullscreen mode Exit fullscreen mode

For this article, I have installed Downshift v7.2.0. Its future versions may change what follows below.

Then, at the beginning of a React component file for the search box (let’s call it SearchBox), import the useCombobox hook:

import {useCombobox} from 'downshift';

export const SearchBox = () => {
};
Enter fullscreen mode Exit fullscreen mode

3.2 Managing autocomplete suggestions as React state

Whenever the list of autocomplete suggestions changes, the UI also needs to change. This means that the array of autocomplete suggestions is best to be managed as a React state the change of which triggers the rerendering of a component and thus the updating of user interface:

import {useState} from 'react'; // ADDED
import {useCombobox} from 'downshift';

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); // ADDED
}
Enter fullscreen mode Exit fullscreen mode

3.3 <input> element markup

To implement the default HTML code for the <input> element as described in Section 2.1 above, we use the getInputProps method from the useCombobox hook:

import {useState} from 'react';
import {useCombobox} from 'downshift';

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); 
  // ADDED FROM HERE
  const {getInputProps} = useCombobox({
    items: autocompleteSuggestions
  });
  return (
    <input {
      ...getInputProps()
    }>  
  );
  // ADDED UNTIL HERE
};
Enter fullscreen mode Exit fullscreen mode

where the items option, required for the useCombobox hook to work, refers to the array of autocomplete suggestions. The getInputProps returns an object whose properties are the HTML attributes for the <input> element. Consequently, the above code renders the input element as follows:

<input
  aria-activedescendant=""
  aria-autocomplete="list"
  aria-controls="downshift-1-menu"
  aria-expanded="false"
  aria-labelledby="downshift-1-label"
  autocomplete="off"
  id="downshift-1-input"
  role="combobox"
  value=""
>
Enter fullscreen mode Exit fullscreen mode

My React app requires a few more attributes to the search box. I can specify them as the options for getInputProps() as follows:

getInputProps({
  inputMode: "search",
  placeholder: "Enter place name or address",
  type: "search"
})
Enter fullscreen mode Exit fullscreen mode

As described in Section 2.1 above, the type="search" attribute makes the iOS/Android mobile keyboard appropriate for a search box. The inputMode prop (see Oliff 2019 for detail) is a fallback when the mobile keyboard does not change as expected (which happened to me with Styled Components for some reason). And the placeholder attribute is handy to suggest what to enter.

Also, I want to add two more attributes to the <input> element. First, I want the search box to be auto-focused when it is opened. With React, the autoFocus prop does the job (see Johnson 2019; see also Day 20 of this blog series):

getInputProps({
  autoFocus: "true", // ADDED
  inputMode: "search",
  placeholder: "Enter place name or address",
  type: "search"
})
Enter fullscreen mode Exit fullscreen mode

However, this will turn the aria-expanded attribute into true, because Downshift, by default, makes it change depending on whether the <input> element is focused or not.

Instead I want the aria-expanded to be true when the user enters any character into the search box, which in turn fills the array of autocompleteSuggestions from Google Maps API calls. So I want to check whether autocompleteSuggestions has any item in its array:

getInputProps({
  "aria-expanded": autocompleteSuggestions.length > 0, // ADDED
  autoFocus: "true", 
  inputMode: "search",
  placeholder: "Enter place name or address",
  type: "search"
})
Enter fullscreen mode Exit fullscreen mode

This way, Downshift allows us to override the default attribute values that it adds.

The other attribute I want to add is aria-label. I have designed the search box with the magnifying glass icon in it, which plays a role of the label to sighted users (see Day 17 of this blog series). For the screen reader user, then, it is enough to label the search box with the aria-label attribute:

getInputProps({
  "aria-expanded": autocompleteSuggestions.length > 0,
  "aria-label": "Search for a place on the map", // ADDED
  autoFocus: "true", 
  inputMode: "search",
  placeholder: "Enter place name or address",
  type: "search"
})
Enter fullscreen mode Exit fullscreen mode

However, Downshift, by default, assumes that there is a <label> element and uses aria-labelledby to refer to the id attribute of the <label> element. I don’t need it. So I override it with null:

getInputProps({
  "aria-expanded": autocompleteSuggestions.length > 0,
  "aria-label": "Search for a place on the map",
  "aria-labelledby": null, // ADDED
  autoFocus: "true", 
  inputMode: "search",
  placeholder: "Enter place name or address",
  type: "search"
})
Enter fullscreen mode Exit fullscreen mode

What is great about Downshift is the ease with which we can customize its implementation. Plus, looking at the code clearly tells us what is customized.

Let’s move on to the <ul> element.

3.4 <ul> element markup

To make the <ul> element accessible as the list of autocomplete suggestions, we use getMenuProps() out of the useCombobox hook:

import {useState} from 'react';
import {useCombobox} from 'downshift';

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); 
  const {
    getInputProps, 
    getMenuProps   // ADDED
  } = useCombobox({
    items: autocompleteSuggestions
  });
  return (
    <>
      <input {
        ...getInputProps({
          "aria-expanded": autocompleteSuggestions.length > 0,
          "aria-label": "Search for a place on the map",
          "aria-labelledby": null,
          autoFocus: "true", 
          inputMode: "search",
          placeholder: "Enter place name or address",
          type: "search"
        })
      }>
      {/* ADDED FROM HERE */}
      <ul
        {...getMenuProps()}
      >
      </ul>
      {/* ADDED UNTIL HERE */}
    </>
  )
};
Enter fullscreen mode Exit fullscreen mode

which renders the <ul> element as follows:

<ul 
  id="downshift-1-menu"
  role="listbox"
  aria-labelledby="downshift-1-label"
></ul>
Enter fullscreen mode Exit fullscreen mode

Remember that the id attribute value, "downshift-1-menu", is used for the aria-controls attribute of the <input> element (see Section 3.3. above).

The aria-labelledby attribute is redundant for my purpose, as there is no <label> element. So I make it null and add aria-label instead:

getMenuProps({
  'aria-label': "Autocomplete suggestions",
  'aria-labelledby': null,
})
Enter fullscreen mode Exit fullscreen mode

That’s all for the default HTML code.

3.5 Showing autocomplete suggestions

For the ease of exposition, let me first mock autocomplete search with a function called mockAutocompleteSearch:

// utils/mockAutocompleteSearch.js
export function mockAutocompleteSearch(searchText) {
  if (searchText === 's') {
    return ["San Francisco", "Sapporo", "Seoul", "Singapore", "Stockholm"];
  } else {
    return [];
  }
}
Enter fullscreen mode Exit fullscreen mode

If the argument is the string of "s", it returns the list of fives cities whose name begins with "s" that I have been to in the past. It is a stupid function, but it is sufficient to explain how Downshift works with any autocomplete search function that maps from the user’s input to the array of suggestions.

To run this function whenever the user enters text into the search box, we use the onInputValueChange option for the useCombobox hook:

import {useState} from 'react';
import {useCombobox} from 'downshift';
import {mockAutocompleteSearch} from '../utils/mockAutocompleteSearch.js'; // ADDED

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]);
  const {    
    getInputProps, 
    getMenuProps
  } = useCombobox({
    items: autocompleteSuggestions,
    // ADDED FROM HERE
    onInputValueChange: ({inputValue}) => {
      if (inputValue === '') {
        setAutocompleteSuggestions([]);
        return;
      }
      setAutocompleteSuggestions(mockAutocompleteSearch(inputValue));
    }
    // ADDED UNTIL HERE
  });
  return (
    // Omitted for brevity
  )
};
Enter fullscreen mode Exit fullscreen mode

The inputValue refers to the text the user enters into the search box. If it’s empty, we set the autocompleteSuggestions array to be empty. Otherwise, we set the array to contain autocomplete suggestions returned by mockAutocompleteSearch().

We can then use the condition

autocompleteSuggestions.length > 0
Enter fullscreen mode Exit fullscreen mode

to decide whether to show the list of autocomplete suggestions:

import {useState} from 'react';
import {useCombobox} from 'downshift';
import {mockAutocompleteSearch} from '../utils/mockAutocompleteSearch.js';

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); 
  const {    
    getInputProps, 
    getMenuProps
  } = useCombobox({
    items: autocompleteSuggestions,
    onInputValueChange: ({inputValue}) => {
      if (inputValue === '') {
        setAutocompleteSuggestions([]);
        return;
      }
      setAutocompleteSuggestions(mockAutocompleteSearch(inputValue));
    }
  });
  return (
    <>
      {/* The search box is omitted for brevity */}
      <ul
        {...getMenuProps({
          'aria-label': "Autocomplete suggestions",
          'aria-labelledby': null,
        })}
      >
        {/* ADDED FROM HERE */}
        {autocompleteSuggestions.length > 0
          ? autocompleteSuggestions.map((suggestion, index) => {
              return (
                <li
                  key={`suggestion-${index}`}
                >
                  {suggestion}
                </li>
              );
            }) 
          : null}
        {/* ADDED UNTIL HERE */}
      </ul>
    </>
  )
};
Enter fullscreen mode Exit fullscreen mode

The use of the map() array method is the standard practice for using React to render the list out of an array (see React Docs on Lists and Keys).

3.6 <li> element markup

Now, to add the relevant ARIA attributes to the <li> elements, we use getItemProps() out of the useCombobox hook:

import {useState} from 'react';
import {useCombobox} from 'downshift';
import {mockAutocompleteSearch} from '../utils/mockAutocompleteSearch.js';

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); 
  const {    
    getInputProps,
    getItemProps,  // ADDED 
    getMenuProps
  } = useCombobox({
    items: autocompleteSuggestions,
    onInputValueChange: ({inputValue}) => {
      if (inputValue === '') {
        setAutocompleteSuggestions([]);
        return;
      }
      setAutocompleteSuggestions(mockAutocompleteSearch(inputValue));
    }
  });
  return (
    <>
      {/* The search box is omitted for brevity */}
      <ul
        {...getMenuProps({
          'aria-label': "Autocomplete suggestions",
          'aria-labelledby': null,
        })}
      >
        {autocompleteSuggestions.length > 0
          ? autocompleteSuggestions.map((suggestion, index) => {
              return (
                <li
                  key={`suggestion-${index}`}
                  // ADDED FROM HERE
                  {...getItemProps({
                    suggestion,
                    index
                  })}
                  // ADDED UNTIL HERE
                >
                  {suggestion}
                </li>
              );
            }) 
          : null}
      </ul>
    </>
  )
};
Enter fullscreen mode Exit fullscreen mode

which will render <li> elements as follows:

<li 
  role="option"
  aria-selected="false"
  id="downshift-1-item-0"
  suggestion="San Francisco"
>
  San Francisco
</li>
<li 
  role="option"
  aria-selected="false"
  id="downshift-1-item-1"
  suggestion="Sapporo"
>
  Sapporo
</li>
<li 
  role="option"
  aria-selected="false"
  id="downshift-1-item-2"
  suggestion="Seoul"
>
  Seoul
</li>
<li
  role="option" 
  aria-selected="false"
  id="downshift-1-item-3"
  suggestion="Singapore"
>
  Singapore
</li>
<li
  role="option" 
  aria-selected="false"
  id="downshift-1-item-3"
  suggestion="Stockholm"
>
  Stockholm
</li>
Enter fullscreen mode Exit fullscreen mode

I don’t know why Downshift adds the suggestion attribute...

3.7 When the user chooses one of the suggestions

The final step to comply with the ARIA Authorizing Practices Guide is to deal with the selection of an autocomplete suggestion.

Actually, with the code written so far, Downshift implements the changes of ARIA attributes in response to the keyboard user’s choice of an autocomplete suggestion. Pressing the Down Arrow key to select the first suggestion converts the HTML code into the following:

<input 
  aria-activedescendant="downshift-1-item-0" // CHANGED!
  aria-autocomplete="list"
  aria-controls="downshift-1-menu" 
  aria-expanded="true" 
  autocomplete="off" 
  id="downshift-1-input" 
  role="combobox" 
  aria-label="Search for a place on the map" 
  inputmode="search" 
  placeholder="Enter place name or address" 
  type="search" 
  value="s">
<ul 
  id="downshift-1-menu"
  role="listbox"
  aria-label="Autocomplete suggestions"
>
  <li 
    role="option" 
    aria-selected="true"    // CHANGED!
    id="downshift-1-item-0" 
    suggestion="San Francisco">
    San Francisco
  </li>
  <li 
    role="option"
    aria-selected="false"
    id="downshift-1-item-1"
    suggestion="Sapporo"
  >
    Sapporo
  </li>
  <li 
    role="option"
    aria-selected="false"
    id="downshift-1-item-2"
    suggestion="Seoul"
  >
    Seoul
  </li>
  <li
    role="option" 
    aria-selected="false"
    id="downshift-1-item-3"
    suggestion="Singapore"
  >
    Singapore
  </li>
  <li
    role="option" 
    aria-selected="false"
    id="downshift-1-item-3"
    suggestion="Stockholm"
  >
    Stockholm
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Notice the aria-activedescendant attribute of the <input> element now points to the id attribute of the first <li> element, which in turn toggles its aria-selected attribute into "true".

However, sighted users won’t see anything as I haven’t styled any of the <li> elements in response to the keyboard user’s selection.

To do so, we use highlightedIndex out of the useCombobox hook:

import {useState} from 'react';
import {useCombobox} from 'downshift';
import {mockAutocompleteSearch} from '../utils/mockAutocompleteSearch.js';

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); 
  const {    
    getInputProps,
    getItemProps,
    getMenuProps,
    highlightedIndex, // ADDED
  } = useCombobox({
    items: autocompleteSuggestions,
    onInputValueChange: ({inputValue}) => {
      // omitted for brevity
    },
  });
  return (
    <>
      {/* The search box is omitted for brevity */}
      <ul
        {...getMenuProps({
          'aria-label': "Autocomplete suggestions",
          'aria-labelledby': null,
        })}
      >
        {autocompleteSuggestions.length > 0
          ? autocompleteSuggestions.map((suggestion, index) => {
              return (
                <li
                  data-highlighted={index === highlightedIndex} // ADDED
                  key={`suggestion-${index}`}
                  {...getItemProps({
                    suggestion,
                    index
                  })}
                >
                  {suggestion}
                </li>
              );
            }) 
          : null}
      </ul>
    </>
  )
};
Enter fullscreen mode Exit fullscreen mode

To check whether or not the keyboard user selects a particular <li> element with its position given by index, we can use the statement index === highlightedIndex, which is true when selected.

To style the selected <li> element, I use the data-highlighted attribute whose value corresponds to the evaluation of index === highlightedIndex. Then, in a CSS stylesheet, style it something like this:

li[data-highlighted="true"] {
  background-color: var(--highlighted);
}
Enter fullscreen mode Exit fullscreen mode

where the color is defined somewhere else as a CSS variable called --highlighted.

This way, we can indicate which autocomplete suggestion is currently selected, like this:
Letter "s" is shown in the search box below which the first suggestion "San Francisco" is highlighted while other four suggestions below, "Sapporo", "Seoul", "Singapore", and "Stockholm" are not.

3.8 When the user selects a suggestion by click

Finally, when the user selects one of the autocomplete suggestions by clicking it, we need to run a script to show the location of it on the map. This is simple: we can just add an onClick prop as an additional argument for getItemProps():

import {useState} from 'react';
import {useCombobox} from 'downshift';
import {mockAutocompleteSearch} from '../utils/mockAutocompleteSearch.js';

export const SearchBox = () => {
  const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); 
  const {    
    getInputProps,
    getItemProps,
    getMenuProps,
    highlightedIndex,
  } = useCombobox({
    items: autocompleteSuggestions,
    onInputValueChange: ({inputValue}) => {
      // omitted for brevity
    },
  });
  return (
    <>
      {/* The search box is omitted for brevity */}
      <ul
        {...getMenuProps({
          'aria-label': "Autocomplete suggestions",
          'aria-labelledby': null,
        })}
      >
        {autocompleteSuggestions.length > 0
          ? autocompleteSuggestions.map((suggestion, index) => {
              return (
                <li
                  data-highlighted={index === highlightedIndex}
                  key={`suggestion-${index}`}
                  {...getItemProps({
                    suggestion,
                    index,
                    // ADDED FROM HERE
                    onClick: event => {
                      // Insert the code for showing the selected place on the map
                    },
                    // ADDED UNIL HERE
                  })}
                >
                  {suggestion}
                </li>
              );
            }) 
          : null}
      </ul>
    </>
  )
};
Enter fullscreen mode Exit fullscreen mode

4. Demo

Here is the CodeSandbox demo for the entire code written in this article.

5. Next up

In the next post of this blog series, I will use the code written in this article but replace the mockAutocompleteSearch function with the one to make a request to Google Maps API so that I can obtain real autocomplete suggestions in response to the user's search text.

Watch this space!

Changelog

Oct 9, 2023 (v1.0.1): Fix typo in Section 1.

References

Holachek, Alex (undated) “Build a Better Mobile Input”, undated.

Johnson, Maisie (2019) “3 ways to autofocus an input in React that ALMOST always work!”, blog.maisie.ink, Oct 25, 2019.

Oliff, Christian (2019) “Everything You Ever Wanted to Know About inputmode”, CSS-Tricks, May 17, 2019.

💖 💪 🙅 🚩
masakudamatsu
Masa Kudamatsu

Posted on February 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related