Day 20: Toggling a search box for keyboard users with React

--

A visual summary of what this article is about (created by the author with Canva)

TL;DR

To toggle the visibility of a search box with React in a keyboard user-friendly manner, do the following:

  1. Use the useEffect hook to trigger a keydown event handler for hiding the search box with the Esc key
  2. Add the autoFocus prop to <input type="search"> so that the user can immediately start typing in the search box
  3. Use the useEffect hook to programatically focus the toggle button after the user closes the search box
  4. Trap the focus inside the search box popup with <FocusLock> from the react-focus-lock module.

The entire code described in this article can be found in the CodeSandbox demo.

Introduction

My Ideal Map, a web app I’m building to improve the UX of Google Maps, allows the user to search for a place on the map. While Google Maps as iOS/Android app always displays a search box on the map, My Ideal Map only shows a search button at the top-right corner to minimize the area of the map hidden from the view. I don’t want the user to fail to notice a place of their own interest beneath the search box.

My Ideal Map’s user interface with the toggle button for a search box at the top-right corner (screenshot by the author)

To search for a place on the map, therefore, the user needs to press the search button to see a search box. And, if they change their mind, the user should be allowed to close the search box and go back to the full-screen view of the map.

Implementing this user experience with React is straightforward if we care about touch device users and mouse users only. But there are keyboard users out there, including those screen reader users. Implementation for these users with React turns out to be not trivial at all.

Below I describe what I have learned, by constructing a React component for the search feature step by step.

1. HTML

To construct a React component, the first thing I do is to forget about React and focus on what HTML code I want React to render.

1.1 Search button

By default, I want the following HTML code:

<form role="search">
<button
aria-expanded="false"
aria-label="Search place"
type="button"
>
<!-- SVG code for the search button (omitted) -->
</button>
</form>

Here are some footnotes on this piece of code:

  • Wrapping the button to open a search box with <form role="search"> allows screen-reader users to notice that the search feature is available on a website (MDN Contributors 2022a).
  • Screen reader users, when focusing on the <button>element, will learn that the search box is currently hidden thanks to aria-expanded="false" (Silver 2019).
  • I use aria-label to give an accessible name to the button, because the button is an icon button with no text label (see Day 16 of this blog series for detail).

Incidentally, I initially used aria-controls to link the button with <input type="search">, to indicate which element the button will open. However, Pickering (2016) points out that only the JAWS screen reader supports the aria-controls attribute. ARIA Authoring Practices Guide on menu button recommends aria-controls only as optional. Apparently, we can forget about aria-controls(let me know if I’m wrong).

1.2 Search box

Now, when the user clicks the button, I want the HTML code to change as follows:

<form role="search">
<div class="popup">
<button
aria-label="Close searchbox"
type="button"
>
<!-- SVG code for a cross icon (x) (omitted) -->
</button>
<div class="searchbox">
<!-- SVG code for a magnifyiing glass (omitted) -->
<input
aria-label="Search for a place on the map"
autocomplete="off"
inputmode="search"
placeholder="Enter place name or address"
type="search"
/>
</div>
</div>
</form>

The <div class="popup"> element is the one created in Day 19 of this blog series, styling the popup like clouds and positionining the close button at the top right corner.

The <button> element will be rendered by the close button component that I have created on Day 18 of this blog series.

The <div class="searchbox"> and its child elements is the search box that I have created on Day 17 of this blog series.

1.3 Rendering HTML with React

Using the CloseButton component created on Day 18, I render the HTML code as described above with the following React component:

import {useState} from 'react';
import {CloseButton} from './components/CloseButton';

export const Search = () => {
const [searchBoxOpen, setSearchBoxOpen] = useState('false');
return (
<form role="search">
{searchBoxOpen === 'false' ? (
<button
aria-expanded="false"
aria-label="Search place"
onClick={() => setSearchBoxOpen('true')}
type="button"
>
{/* SVG code for a magnifyiing glass (omitted) */}
</button>
) : (
<div className="popup">
<CloseButton
ariaLabel="Close searchbox"
handleClick={() => setSearchBoxOpen('false')}
/>
<div className="searchbox">
<input
aria-label="Search for a place on the map"
autoComplete="off"
inputMode="search"
placeholder="Enter place name or address"
type="search"
/>
</div>
</div>
)}
</form>
);
};

I create a state variable called searchBoxOpen with the useState hook. When it is false, the toggle button is rendered. When it is true, the search box popup is rendered.

Note also that we now need to use the camelCase for autoComplete and inputMode props in place of autocomplete and inputmode HTML attributes.

Now let me get to the main point of this article: handling keyboard users.

2. Focus management

Keyboard users need to control which interactive element is currently focused. For this purpose, there are three things to implement: (1) focus the search box after opening it; (2) focus the button to open the search box after closing it; (3) trap the focus within the popup showing the search box.

2.1 After opening the search box

For good user experience, auto-focusing the search box is critical:

Don’t make people click twice when once (or not at all) will do. In some cases, the user has to first click the icon to open the search box and then click to move the input focus into the search field. One click on the icon should put the cursor in the field, ready for typing. — Sherwin (2014)

This feature is not just for keyboard users; touch device users and mouse users will also benefit from it.

For implementation, Johnson (2019) describes three ways of auto-focusing an interactive element in React: the autoFocusprop, the combination of useEffect and useRef, and callback refs. She concludes:

I would recommend that you use the autoFocus prop, unless you have odd circumstances (like I did) that prevent it from working. — Johnson (2019)

Following this piece of advice, I add the autoFocus prop to the <input type="search"> element:

export const Search = () => {
const [searchBoxOpen, setSearchBoxOpen] = useState('false');
...
return (
<form role="search">
{searchBoxOpen === 'false' ? (
<button
aria-expanded="false"
aria-label="Search place"
onClick={() => setSearchBoxOpen('true')}
type="button"
>
{/* SVG code for a magnifyiing glass (omitted) */}
</button>
) : (
<div className="popup">
<CloseButton
ariaLabel="Close searchbox"
handleClick={() => setSearchBoxOpen('false')}
/>
<div className="searchbox">
<input
aria-label="Search for a place on the map"
autoComplete="off"
autoFocus // ADDED
inputMode="search"
placeholder="Enter place name or address"
type="search"
/>
</div>
</div>
)}
</form>
);
};

This way, whenenver the user presses the magnifying glass button, the popup opens up with the search box focused automatically.

Unfortunately, iOS Safari users cannot immediately type into the auto-focused search box, though. Here is Apple’s take:

We (Apple) ... do not want programmatic focus to bring up the keyboard when you do not have a hardware keyboard attached and the programmatic focus was not invoked in response to a user gesture. Why you may ask...because auto bringing up the software keyboard can be seen as annoying and a distraction to a user... — Bates (2019)

We just need to accept this iOS Safari’s behavior. There is no simple way around, it seems.

Incidentally, auto-focusing has an accessibility issue:

Automatically focusing a form control can confuse visually-impaired people using screen-reading technology and people with cognitive impairments. When autofocus is assigned, screen-readers "teleport" their user to the form control without warning them beforehand. — MDN Contributors (2022b)

Since I have labelled <input type="search"> with aria-label="Search for a place on the map", screen reader users will learn what kind of interactive element they are now focusing on.

2.2 After closing the search box

After the user closes the popup for search, the button that has opened the popup should be auto-focused. That’s the recommendation in the ARIA Authoring Practices Guide:

When a dialog closes, focus returns to the element that invoked the dialog... — ARIA Authoring Practices Guide for “Dialog (Modal)”

This feature is convenient when the keyboard user mistakenly presses the close button. In this case, they want to go back to the search box immediately. With the button to open it automatically focused, they can just hit the Enter key to undo their mistake.

For implementation with React, we cannot use the autoFocus prop this time. The button to open the search box is rendered every time the user accesses to the app. We don’t want the search button to be always focused; the user may want to open the menu first or locate themselves on the map by pressing the locator button (see Day 12).

Instead we want to auto-focus the search button only after the search box popup is closed. This happens when the searchBoxOpen state changes from true to false. We can regard the auto-focusing of the seach button as a side effect of state change. That is when we should use the useEffect hook.

So we can use what Johnson (2019) describes as the second approach of auto-focusing an element in React: the combination of useEffect and useRef.ja

First, refer to the toggle button with the useRef hook:

...
const buttonElement = useRef();
...
return (
...
<button
aria-expanded="false"
aria-label="Search place"
onClick={() => setSearchBoxOpen('true')}
ref={buttonElement} // ADDED
type="button"
>
...
)

Then, use the useEffect hook to run the following code whenever the search box is closed:

  useEffect(() => {
if (searchBoxOpen === 'false') {
buttonElement.current.focus();
}
}, [searchBoxOpen]);

Well, the above useEffet code block doesn’t work quite. It will auto-focus the search button when the button is first rendered (that is, when the user first accesses the app). The reason is that the useEffect hook only picks the current state, not the previous state as well.

I think there are a few ways around. One solution is to record whether the close button is pressed with the useRef hook. So revise the click handler of the close button as follows:

...
// ADDED FROM HERE
const closeButtonPressed = useRef();
const handleClickCloseButton = () => {
closeButtonPressed.current = true;
setSearchBoxOpen('false');
}
// ADDED UNTIL HERE
...
<CloseButton
ariaLabel="Close searchbox"
handleClick={handleClickCloseButton} // REVISED
/>
...

Then, we can check whether closeButtonPressed.current is true before auto-focusing the search button:

...
const buttonElement = useRef();
useEffect(() => {
if (searchBoxOpen === 'false') {
// REVISED FROM HERE
if (closeButtonPressed.current === true) {
buttonElement.current.focus();
}
// REVISED UNTIL HERE
}
})
...

So the entire code is as follows:

import {useEffect, useRef, useState} from 'react'; // REVISED
import {CloseButton} from './components/CloseButton';

export const Search = () => {
const [searchBoxOpen, setSearchBoxOpen] = useState('false');
...

// ADDED FROM HERE
const buttonElement = useRef();
const closeButtonPressed = useRef();

const handleClickCloseButton = () => {
closeButtonPressed.current = true;
setSearchBoxOpen('false');
}

useEffect(() => {
if (searchBoxOpen === 'false') {
if (closeButtonPressed.current === true) {
buttonElement.current.focus();
}
}
}, [searchBoxOpen]);
// ADDED UNTIL HERE

return (
<form role="search">
{searchBoxOpen === 'false' ? (
<button
aria-expanded="false"
aria-label="Search place"
onClick={() => setSearchBoxOpen('true')}
ref={buttonElement} // ADDED
type="button"
>
{/* SVG code for a magnifying glass (omitted) */}
</button>
) : (
<div className="popup">
<CloseButton
ariaLabel="Close searchbox"
handleClick={handleClickCloseButton} // REVISED
/>
<div className="searchbox">
<input
aria-label="Search for a place on the map"
autoComplete="off"
autoFocus
inputMode="search"
placeholder="Enter place name or address"
type="search"
/>
</div>
</div>
)}
</form>
);
};

This solution can also handle the situation where the searchbox popup closes when the user selects one of the autocomplete suggestions (which will be implemented in one of the next posts of this blog series). In this case, the app will autofocus the popup showing the search result, not the search button. So we can create a click handler for the search result element like so:

const closeSearchBox = () => {
closeButtonPressed.current = false;
setSearchBoxOpen('false');
};

This way, the code block of useEffect for auto-focusing will not run.

2.3 Trap the focus within the popup

Finally, we want to trap the focus within the search box popup. Otherwise, keyboard users can end up having a focus on one of the place marks in the map beneath the popup. Again, let me cite ARIA Authoring Practices Guide:

If focus is on the last tabbable element inside the dialog, [Tab] moves focus to the first tabbable element inside the dialog.

If focus is on the first tabbable element inside the dialog, [Shift + Tab] moves focus to the last tabbable element inside the dialog.

ARIA Authoring Practices Guide for “Dialog (Modal)”

The implementation of focus trap is very complicated to code from scratch. See Giraudel (2021) for how complex the code can be.

More recently, web developers have been talking about the inert attribute to trap the focus (see Klavenes 2022, for example). As of November 2022, however, Firefox is yet to support the inert attribute (source: Can I Use?). We need to wait for a bit more.

The solution I’ve adopted is the react-focus-lock library, used by Atlassian AtlasKit, ReachUI, SmoothUI, and Storybook among others.

With its FocusLock component, it’s remarkably simple to implement the focus trap:

...
import FocusLock from 'react-focus-lock'; // ADDED

export const Search = () => {
...
return (
<form role="search">
{searchBoxOpen === 'false' ? (
<button
aria-expanded="false"
aria-label="Search place"
onClick={() => setSearchBoxOpen('true')}
ref={buttonElement}
type="button"
>
{/* SVG code for a magnifying glass (omitted) */}
</button>
) : (
<FocusLock> {/* ADDED */}
<div className="popup">
<CloseButton
ariaLabel="Close searchbox"
handleClick={() => setSearchBoxOpen('false')}
/>
<div className="searchbox">
<input
aria-label="Search for a place on the map"
autoComplete="off"
autoFocus
inputMode="search"
placeholder="Enter place name or address"
type="search"
/>
</div>
</div>
</FocusLock> {/* ADDED */}
)}
</form>
);
};

We just need to wrap the popup with <FocusLock>. This way, once the popup is open, pressing the Tab key will cycle the focus within the popup (in this case, the focus moves between the search box and the close button only).

3. Close with ESC key

For keyboard users, pressing the ESC key is the standard method for closing a popup. In the ARIA Authoring Practices Guide for “Dialog (Modal)”, the pressing of the Esc key to close the dialog is listed as one of keyboard interaction requirements.

How can we implement this feature with React?

Woodson (2020) gives me a clue. He suggests using the useEffect hook like this:

useEffect(() => {
window.addEventListener('keydown', (event) => {
// ...
});
}, []);

The code block inside the useEffect hook will be run after the HTML code is rendered. Therefore, it allows the keydownevent handler to be attached to the window object that will not be created until the HTML code is rendered.

However, the keydown event is fired on the document object, not the window object, according to the MDN Web Docs on “Event reference”. So I’m going to use document.addEventListener().

Let’s first work out the code inside the useEffect hook. I define an keydown event handler as closeByEsc:

const closeByEsc = event => {
if (event.key === 'Escape') {
handleClickCloseButton();
}
};

If the pressed key, available as event.key, is the Esc key, this handler runs the handleClickCloseButton function defined in the previous section:

  const handleClickCloseButton = () => {
closeButtonPressed.current = true;
setSearchBoxOpen('false');
}

This way, after pressing the Esc key to close the popup, the toggle button will be auto-focused, just like after pressing the close button.

Then, when the search box is opened, attach this event handler to document:

if (searchBoxOpen === 'true') {
document.addEventListener('keydown', closeByEsc);
}

When the search box is closed, remove this event handler from document:

if (searchBoxOpen === 'true') {
document.addEventListener('keydown', closeByEsc);

// ADDED FROM HERE
} else {
document.removeEventListener('keydown', closeByEsc);
// ADDED UNTIL HERE

}

This way, pressing the Esc key will close the search box if and only if the search box is open. When another popup is open, I want them to be closed with the pressing of the Esc key as well. Each time a popup is opened (or closed), I will attach (or remove) the event handler dedicated to closing that particular popup.

Then insert them all into the useEffect hook code block:

useEffect(() => { // ADDED
const closeByEsc = event => {
if (event.key === 'Escape') {
handleClickCloseButton();
}
};
if (searchBoxOpen === 'true') {
document.addEventListener('keydown', closeByEsc);
} else {
document.removeEventListener('keydown', closeByEsc);
}
}, [searchBoxOpen]); // ADDED

where [searchBoxOpen], the second argument for the useEffect hook, makes the useEffect code block run each time the value of searchBoxOpen changes. This is critical to attach and remove the event listener each time the search box is opened and closed.

Finally, in case the Search component is dismounted while the search box remains open, we want to remove the event listener. To implement this, we use the return statement:

useEffect(() => {
const closeByEsc = event => {
if (event.key === 'Escape') {
handleClickCloseButton();
}
};
if (searchBoxOpen === 'true') {
document.addEventListener('keydown', closeByEsc);
} else {
document.removeEventListener('keydown', closeByEsc);
}

// ADDED FROM HERE
return () => {
document.removeEventListener('keydown', closeByEsc);
}
// ADDED UNTIL HERE

}, [searchBoxOpen]);

The complete code for the Search component (including the one described in the previous section) is now as follows:

import FocusLock from "react-focus-lock"; // for trapping focus inside popup
import { useEffect, useRef, useState } from "react";
import { ButtonCloud } from "../styled-components/ButtonCloud";
import { CloseButton } from "./CloseButton";

export const Search = () => {
const [searchBoxOpen, setSearchBoxOpen] = useState("false");

// Auto-focus the search button after closing search box
const buttonElement = useRef();
const closeButtonPressed = useRef();
const handleClickCloseButton = () => {
closeButtonPressed.current = true;
setSearchBoxOpen("false");
};
useEffect(() => {
if (searchBoxOpen === "false") {
if (closeButtonPressed.current === true) {
buttonElement.current.focus();
}
}
});

// Close search box with Esc key
useEffect(() => {
const closeByEsc = (event) => {
if (event.key === "Escape") {
handleClickCloseButton();
}
};
if (searchBoxOpen === "true") {
document.addEventListener("keydown", closeByEsc);
} else {
document.removeEventListener("keydown", closeByEsc);
}
return () => {
document.removeEventListener("keydown", closeByEsc);
};
}, [searchBoxOpen]);

return (
<form role="search">
{searchBoxOpen === "false" ? (
<button
aria-expanded="false"
aria-label="Search place"
onClick={() => setSearchBoxOpen("true")}
ref={buttonElement}
type="button"
>
{/* SVG code for a magnifying glass (omitted) */}
</button>
) : (
<FocusLock> {/* trap focus inside popup */}
<div className="popup">
<CloseButton
ariaLabel="Close searchbox"
handleClick={handleClickCloseButton}
/>
<div className="searchbox">
<input
aria-label="Search for a place on the map"
autoComplete="off"
autoFocus // Auto-focus search box when opened
inputMode="search"
placeholder="Enter place name or address"
type="search"
/>
</div>
</div>
</FocusLock>
)}
</form>
);
};

Demo

Here is the CodeSandbox demo for the entire code discussed in this article. With this demo, try the following:

  • Press the magnifying glass icon button at the top-right corner. You should be able to enter text in the search box (unless you use iOS Safari).
  • Press the Tab key to see the focus ring move between the search box and the close button.
  • Press the Esc key to close the search box.
  • After closing the search box, press the Enter key, which should open the search box again.

References

Bates, Daniel (2019) “So, I've got some good news 🙂 and some bad news 🙁 for you. Let's do good news first...”, Comment to WebKit Bugzilla, Mar 19, 2019.

Giraudel, Kitty (2021) “Creating An Accessible Dialog From Scratch”, Smashing Magazine, Jul 28, 2021.

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

Klavenes, Lars Magnus (2022) “Accessible modal dialogs using inert”, larsmagnus.co, Jun 11, 2022.

MDN Contributors (2022a) “”, MDN Web Docs, last modified on Dec 11, 2022.

MDN Contributors (2022b) “: The Input (Form Input) element”, MDN Web Docs, last modified on Nov 26, 2022.

Pickering, Heydon (2016) “Aria-Controls is Poop”, Heydonworks, Aug 21, 2016.

Sherwin, Katie (2014) “The Magnifying-Glass Icon in Search Design: Pros and Cons”, Nielsen Norman Group, Feb 23, 2014.

Silver, Adam (2019) “Why, How, and When to Use Semantic HTML and ARIA”, CSS-Tricks, May 7, 2019.

Woodson, Marques (2020) “Event Listeners in React Components”, Pluralsight, Jun 12, 2020.

--

--