Part 1 - Accessible autocomplete input component in React - Setup

Published at: , ~ 6 mins

This is the 1st part in a series -

  1. Part 1- Create a working autocomplete component (this post)
  2. Part 2 - Making the component accessible
  3. Part 3 - Refactoring

We'll be using React as the UI framework and Vite as the build tool.

Prerequisites -

  1. Make sure you have nodejs and npm or yarn on your system. We'll be using yarn but is not mandatory.
  2. Some familiarity with React.

Fire up your terminal and run these commands to set up the base project.

yarn create @vitejs/app autocomplete --template react

This command sets up a Vite project with react base template. autocomplete is the directory name in which we'll be working. Once this runs, you'll be prompted to run three more commands to enter into your terminal. This is to enter into your project directory and install the project dependencies.

cd autocomplete
yarn
yarn dev

yarn dev starts your application. You'll see the local url to access this project in terminal. In my case, it was http://localhost:3000.

Now, let's delete all the contents of src/App.css and change the code of src/App.jsx to

import React from 'react';
import './App.css';
function App() {
return <div className="ac__container"></div>;
}
export default App;

We did this to reset the base template and start from scratch. We'll update this template to render an input and a list of static suggestions while styling it. After that, we'll add life to this component by handling user input.

After adding the HTML structure and styling, this latest code becomes -

import React from 'react';
import './App.css';
function App() {
return (
<div className="ac__container">
<input type="text" className="ac__input" />
<ul className="suggestion-list">
<li className="suggestion-list-item">Suggestion 1</li>
<li className="suggestion-list-item suggestion-list-item--selected">
Suggestion 2
</li>
<li className="suggestion-list-item">Suggestion 3</li>
<li className="suggestion-list-item">Suggestion 4</li>
<li className="suggestion-list-item suggestion-list-item--hovered">
Suggestion 5
</li>
<li className="suggestion-list-item">Suggestion 6</li>
<li className="suggestion-list-item">Suggestion 7</li>
<li className="suggestion-list-item">Suggestion 8</li>
<li className="suggestion-list-item">Suggestion 9</li>
<li className="suggestion-list-item">Suggestion 10</li>
</ul>
</div>
);
}
export default App;
src/App.jsx
.ac__container {
position: relative;
max-width: 200px;
}
.ac__input {
box-sizing: border-box;
display: block;
width: 100%;
padding: 5px 10px;
margin: 0px;
border: 1px solid #111;
font-size: 1.2rem;
}
.suggestion-list {
position: absolute;
left: 0;
right: 0;
padding: 0;
margin: 5px 0 0 0;
background-color: white;
border: 1px solid #111;
max-height: 300px;
overflow: auto;
border-radius: 3px;
}
.suggestion-list-item {
padding: 5px 10px;
display: flex;
align-items: center;
border-bottom: 1px solid rgb(204, 204, 204);
cursor: pointer;
transition: background-color 0.2s;
}
.suggestion-list-item:last-child {
border-bottom-color: transparent;
}
.suggestion-list-item--hovered,
.suggestion-list-item:hover,
.suggestion-list-item--selected {
background-color: #0e76b3;
color: white;
}
.suggestion-list-item--selected {
background-color: #0d6294;
}
src/App.css

Notice above that we have two classnames for the suggestion list item, hovered and selected. selected is added to the list item that has been currently selected by user and hovered will be added to the item that is hovered over by the user either using a mouse or using the arrow keys.

The UI will now look like this. The list will be generated from dynamic data that we fetch from the api endpoint. -

UI with static html and basic styling

Now let's make this boring UI interactive. First order of action will be to have a state to hold the list of suggestions and iterate over this array to generate our suggestion UI.

function App() {
const [suggestions, setSuggestions] = React.useState([]);
return (
<div className="ac__container">
<input type="text" className="ac__input" />
{!!suggestions.length && (
<ul className="suggestion-list">
{suggestions.map((suggestion) => {
return (
<li key={suggestion.name} className="suggestion-list-item">
{suggestion.name}
</li>
);
})}
</ul>
)}
</div>
);
}
Updated src/App.jsx

With this change, you'll see that the list is not visible because our suggestion list is empty.

We'll use the Clearbit api to fetch the suggestions when users try to type in a company name, which has this structure as the response for the individual suggestion item -

{
"name": "Google",
"domain": "google.com",
"logo": "https://logo.clearbit.com/google.com"
}
The first item in response of https://autocomplete.clearbit.com/v1/companies/suggest?query=google

This structure is what will be stored as the individual array item of suggestions. Let's wire up our input to fetch data from this api as users start typing.

function App() {
const [suggestions, setSuggestions] = React.useState([]);
function fetchSuggestions(query) {
const urlParams = new URLSearchParams();
urlParams.append('query', query);
fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?${urlParams}`)
.then((resp) => resp.json())
.then((newSuggestions) => setSuggestions(newSuggestions));
}
function handleInputChange(ev) {
fetchSuggestions(ev.target.value);
}
return (
<div className="ac__container">
<input type="text" className="ac__input" onChange={handleInputChange} />
{!!suggestions.length && (
<ul className="suggestion-list">
{suggestions.map((suggestion) => {
return (
<li key={suggestion.name} className="suggestion-list-item">
{suggestion.name}
</li>
);
})}
</ul>
)}
</div>
);
}
src/App.jsx

Now, when you preview the latest change in your browser, you'll see your autocomplete component come alive. As you type, it'll show you the suggestions. It's that simple.

Well, not really. We still have to allow users to select an individual suggestion through mouse or arrow keys. Allowing users to select an item can be as simple as just setting the value of the input element with the selected item or as flexible as allowing the users of your component to get all the data they need to render the selected item in a customised way. For simplicity, we'll just update the value of our input to the selected item's name.

We'll need a new state to store the currently highlighted item's index. Let's also use the classnames npm package to conditionally add classes to our suggestion items by installing it -

yarn add classnames or npm install classnames

We'll now do some big changes here. Now, our requirement is explicitly changing the value of input element to whatever the user selects. So, we'll use a state to store the input value so that we can update it programatically as well. We'll also need a state to store the currently selected suggestion item data as well as currently highlighted item. The initial highlighting will be done using onMouseOver event on the li elements. We'll also need a state to store the show/hide the suggestions regardless of whether there is any suggestions or not so that we can hide the list when a particular item is selected by the user and show it again when user focusses in the input. The latest change will look like this -

import React from 'react';
import classNames from 'classnames';
import './App.css';
function App() {
const [suggestions, setSuggestions] = React.useState([]);
const [userInput, setUserInput] = React.useState('');
const [hoveredItemIndex, setHoveredItemIndex] = React.useState(-1);
const [selectedItem, setSelectedItem] = React.useState(null);
const [showSuggestions, setShowSuggestions] = React.useState(false);
function fetchSuggestions(query) {
const urlParams = new URLSearchParams();
urlParams.append('query', query);
fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?${urlParams}`)
.then((resp) => resp.json())
.then((newSuggestions) => setSuggestions(newSuggestions));
}
function handleInputChange(ev) {
setUserInput(ev.target.value);
fetchSuggestions(ev.target.value);
}
function handleItemSelect(index) {
const selectedItem = suggestions[index];
if (!selectedItem) {
return;
}
setSelectedItem(selectedItem);
setShowSuggestions(false);
}
React.useEffect(() => setUserInput(selectedItem?.name ?? ''), [selectedItem]);
return (
<div className="ac__container">
<input
type="text"
className="ac__input"
value={userInput}
onChange={handleInputChange}
onFocus={() => setShowSuggestions(true)}
/>
{!!suggestions.length && showSuggestions && (
<ul
className="suggestion-list"
onMouseOut={() => setHoveredItemIndex(-1)}
>
{suggestions.map((suggestion, index) => {
return (
<li
key={suggestion.name}
className={classNames('suggestion-list-item', {
'suggestion-list-item--hovered': hoveredItemIndex === index,
'suggestion-list-item--selected':
selectedItem?.name === suggestion.name,
})}
onMouseOver={() => setHoveredItemIndex(index)}
onClick={() => handleItemSelect(index)}
>
{suggestion.name}
</li>
);
})}
</ul>
)}
</div>
);
}
export default App;

Above, in line 21, we are also setting the input value in handleInputChange. We are passing new props to the input element, value which is the local state and onFocus handler that'll set the state to start showing the suggestions. handleItemSelect is called when a li element is clicked on by the user to select that suggestion. We've also added a onMouseOut handler to the ul element so that we can remove the highlight when user moves out the mouse from the suggestion list. In line 34, we are using useEffect to explicitly set the value of the input to whatever suggestion item is selected.

Let's also update the fetch call to handle error if backend returns any -

function fetchSuggestions(query) {
const urlParams = new URLSearchParams();
urlParams.append("query", query);
fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?${urlParams}`)
.then((resp) => {
if (!resp.ok) {
throw new Error();
}
return resp.json();
})
.then((newSuggestions) => {
setHoveredItemIndex(0);
setSuggestions(newSuggestions);
})
.catch(() => {
setHoveredItemIndex(-1);
setSuggestions([]);
});
}

This will reset the hovered item as well as the suggestion list if we encounter any backend error.

Now, let's move on to actually make this component accessible which is what Part 2 of this series is all about.