Agustin Barrientos
All posts

Building an Autocomplete Combobox That Stays in Sync With Slow Results

Want more of these in your Google results?

A lot of apps have a field that suggests people as you type, like the “To” field in Gmail or the “Assignee” field in Jira. Some of them have a bug where the items on the list jump around while you’re choosing an option.

Because of that, the focused option can end up on a different person. You’d usually notice and fix your pick, but it’s pretty annoying to have to watch the list instead of typing the name and moving on.

It happens because every letter you type starts another search on the server. Until the search for your last letter finishes, you’re seeing the results of an older one.

And the searches don’t always finish in the order you started them. Short searches like “m” match more people and can take longer. Sometimes the results for “m” come in last and replace the list you’re choosing from.

Google’s own Place Autocomplete sample for Maps numbers its requests to avoid this and skips the results if a newer request has started.

Not every picker checks, though.

Take Select2, a jQuery library that replaces select boxes with nice-looking ones. Its users have been reporting this bug since 2015, but it still gets about 676,000 downloads a week on npm.

Someone reported that in their system, barcode scanners get the code, search for it on a list, and then press Enter right away. That means Select2 picks the item from the previous scan every single time, because the Enter always comes before the results for the new code. Select2’s bot closed the report for inactivity in 2019, but a comment from 2023 says it was never fixed.

GitHub has an open-source <auto-complete> element, a web component that shows suggestions from a server under a text field. It has a data-autoselect option for Enter to pick the first suggestion.

I set it up on a test page with a server that had twelve people and responded in 0.2 seconds. I typed “ma” and waited for the list of six people, with “Madison” first. Next I typed “rg” to narrow it down to “Margaret” and pressed Enter right away.

All three times I tried it, I got Madison.

The reason is that GitHub’s element starts its search after a 0.3-second pause in your typing. Counting my server’s 0.2 seconds, the list on screen stayed the one for “ma” for about half a second, long enough for my Enter to pick its first person.

The demo below has an “Assignee” field with the same kind of bug. Like in Select2, Enter picks the first person in the list.

Click the “Assignee” field to see the people you assign most. Type “marg” at your normal speed and press Enter right away, the way you would if you knew who you wanted. Click the field again and type “marg” a second time, and just watch the list for a couple of seconds.

For more than half a second from the moment you typed the last letter, the list kept showing the suggestions, with Ryan highlighted. If you pressed Enter in that time, Ryan got the task.

The second time, Margaret showed up alone at first, until the results for the shorter queries came in and replaced her list. The highlight stayed on the first row and jumped from Margaret to Marcus to Madison, with “marg” in the field.

Maybe you’d notice the jump and move down to Margaret yourself. But, besides being annoying, it could also happen that a person who types quickly doesn’t wait for the list to appear.

So there are four different things you have to keep track of separately: what you typed, who’s highlighted, who you picked, and the id the form sends.

In this article I’ll build an assignee field where you never see the results of older searches or pick from an old list. Plus, I’ll tie the highlight to a person rather than a row and send the person you picked with the form.

I also tested how Angular Material and three popular React libraries handle results that come back out of order, with a test you can run on your own picker.

Hopefully it’s useful if you build or review fields like this one.

The markup

People call a field like this an “autocomplete”, but accessibility specs call it a “combobox”.

In my article on styling the native select, I said it’s better to use a searchable select if there are more than 10 options, because typing what you want is way faster and easier than scrolling to it.

That said, there’s no native HTML element that searches a server as you type and sends the person’s id with the form instead of their name. No browser has a select you filter by typing, but thankfully and finally there’s an open proposal to add one.

HTML does have <datalist>, though, which is a list of suggestions you can attach to an input.

However, when you pick a suggestion, the browser writes its value into the field. So with ids like “p03” as the values, the field would show an id where the name should be. Plus, the browser filters and draws that list itself, in a way you can’t change.

So our picker uses plain HTML elements instead. The field is an <input type="text"> with a label. On it, we use a few ARIA attributes (attributes that describe an element and its state to screen readers):

  • role="combobox" to tell screen readers it’s a text field with suggestions.
  • aria-autocomplete="list" to say the suggestions match what you’ve typed so far.
  • aria-controls="assignee-list" to point the field to its list.
  • aria-expanded, set to true while the list is open and has people in it.

Then we use autocomplete="off" to stop the browser from showing a list of your past entries on top of the picker’s.

Next, we add a hidden input for the person’s id. Each person in the list will be an <li role="option">, inside a <ul> with role="listbox" and an aria-label.

Last is a visually hidden, empty paragraph with role="status". Screen readers only announce changes to its text if the paragraph was already in the page ahead of the first message. That’s why it starts out empty.

Its aria-atomic="true" means screen readers announce the whole message on each change.

html
<label for="assignee">Assignee</label>
<input
	id="assignee"
	type="text"
	role="combobox"
	autocomplete="off"

	aria-autocomplete="list"
	aria-controls="assignee-list"
	aria-expanded="false"
>
<input type="hidden" name="assignee" value="">
<ul id="assignee-list" role="listbox" aria-label="People"></ul>
<p class="visually-hidden" role="status" aria-atomic="true"></p>

The field’s type is text rather than search, for two reasons. First, the ARIA in HTML rules don’t allow role="combobox" on a search field. Second, Chrome clears a search field on Escape, but we need that key to close the picker.

Focus stays in the text field the whole time, even as you move the highlight through the list with the arrow keys. That way typing and the caret keep working as usual, along with input methods (the software people use to type languages like Japanese and Chinese).

Screen readers announce the option whose id is in the field’s aria-activedescendant attribute. That’s how the W3C’s ARIA Authoring Practices Guide (APG) describes a combobox with a list.

Searching as the user types

We start with the first version, where we listen for input events and pass the text to searchPeople(). In the demos, that function stands in for a fetch() to a search endpoint. Once the results are in, we draw one <li role="option"> per person with drawOptions().

Before you type anything, the list shows suggestions. Gmail’s suggested contacts work the same way, based on things like who you’ve starred or emailed recently. As soon as you click the field, we call searchPeople() with an empty query. You get the five people you assign most, who show up again whenever you clear the text.

Here we store the highlight as just a number, activeIndex. It’s the position of the highlighted row, or -1 if nothing is highlighted. Whenever results come in, we set it to 0 to highlight the first person.

first-picker.js
// first-picker.js
let results = [];
let activeIndex = -1;

$input.addEventListener( 'input', async () => {
	results = await searchPeople( $input.value );
	activeIndex = 0;
	drawOptions();
} );

Down moves activeIndex one row further. Enter picks the person at that position in results, whose name and id we write into the field and the hidden input with pick().

first-picker.js
// first-picker.js
$input.addEventListener( 'keydown', ( event ) => {
	if ( event.key === 'ArrowDown' ) {
		event.preventDefault();
		activeIndex = Math.min( activeIndex + 1, results.length - 1 );
		drawOptions();
	}
	if ( event.key === 'Enter' && results[activeIndex] ) {
		event.preventDefault();
		pick( results[activeIndex] );
	}
} );

Downshift’s basic example picks by row number too, which is why Enter picked Marcus in my test.

There’s a second problem with the input event that shows up even on a fast network. It fires as you compose text with an input method.

For example, to write Japanese, you start by typing the sounds. Your input method shows them as hiragana (a script for Japanese sounds) until you pick the final characters from a list. You’d search for that half-finished text and get results you’ll throw away.

Browsers fire compositionstart at the start of that kind of typing and compositionend as you confirm the text. Between the two, we skip the searches and run a single one on compositionend.

Firefox follows it with one more input event, but Chrome and Safari don’t.

From both the input and the compositionend listener, we pass the text to startSearch(), where we trim it and skip a query that matches the last one. So we never start a second search for Firefox’s extra event.

picker.js
// picker.js
let composing = false;
$input.addEventListener( 'compositionstart', () => {
	composing = true;
} );
$input.addEventListener( 'compositionend', () => {
	composing = false;
	startSearch( $input.value );
} );
$input.addEventListener( 'input', () => {
	if ( ! composing ) {
		startSearch( $input.value );
	}
} );

When an older response arrives last

As you typed “marg” in the first demo, you started four requests, for “m”, “ma”, “mar”, and “marg”. All four were running at the same time.

Because searches with shorter queries take longer, the results for “m” arrived last, so you ended up with results for “m” displayed under a field that said “marg”.

The fix has two parts.

First, cancel the requests for older queries. Each query gets a new AbortController (the object you use to cancel a fetch()). You pass the controller’s signal to fetch() to be able to reject the request later with abort().

I used a signal like this in my router article to stop a message from loading if you pressed Back. Here, the previous request is useless as soon as you type the next letter, but without an abort, both requests keep running.

Each time the field changes, we call startSearch() and save the trimmed text as currentQuery. That’s the query the list should show results for.

Next, we abort the previous request and pass the query and the new controller to load(). Then the browser cancels the old request and stops downloading its response.

currentQuery starts as null, since we search for the suggestions with an empty string. Otherwise we’d skip their first search as a repeat.

picker.js
// picker.js
let currentQuery = null;
let request = null;

function startSearch( text ) {
	const query = text.trim();
	if ( query === currentQuery ) {
		return;
	}
	currentQuery = query;
	request?.abort();
	request = new AbortController();
	load( query, request );
}

Second, check the results you’re about to draw.

Aborting isn’t enough on its own. Once a response is parsed, an abort has no effect on it. Whatever your code does with those results runs anyway, like sorting the people or adding their avatars. That’s why React’s docs say an ignore flag is the most reliable fix and mention the abort as an extra.

And some searches can’t be aborted at all, for example fetchAutocompleteSuggestions() in Google’s Places API, which doesn’t take a signal.

So in load(), we check whether its controller is still the one in request, the one for your latest search. If it isn’t, we drop the results of that older search without calling showResults().

Checking the text isn’t enough. Say you type “mar” and delete the “r”. You’ve started two requests for “ma”, one before the “r” and one after it. On a search that can’t be aborted, the results of the older one would match the text too.

That includes the suggestions, which get dropped like everything else from an older search once you start typing.

Errors need their own check. Aborted requests reject too, so their errors reach the same catch as real failures, like a lost connection.

You’ll often see people tell them apart by checking whether the error’s name is 'AbortError', but the name isn’t always that. Depending on the browser and the moment you abort, the error can be the reason you passed to abort(), or a TimeoutError for a timeout. Once the request has started, WebKit rejects with a generic AbortError whatever the reason.

Instead, we check the request’s signal.aborted in the catch of load(). That property turns true however the request was aborted.

Errors from older requests get dropped like their results. For real failures, we call showError() to show the error message.

picker.js
// picker.js
async function load( query, controller ) {
	try {
		const people = await searchPeople( query, { signal: controller.signal } );
		if ( controller === request ) {
			showResults( people );
		}
	} catch {
		if ( ! controller.signal.aborted && controller === request ) {
			showError();
		}
	}
}

Debouncing the search doesn’t change the order of the responses. A debounce means fewer requests, as the search only starts when you stop typing for a moment. But you send one at every pause in the middle of a name too, with results that can still come in last.

Between your last key and the results for the new text, the list on screen is the one from the previous search. That’s how Select2 and GitHub’s element end up picking the wrong person when you press Enter too soon.

So in the finished picker, we ignore Down, Up, Enter, and clicks on the list during that time.

I’d rather make you press a key twice than give the task to a person you didn’t choose.

You keep seeing the old list, though we dim it and mark it with aria-busy="true" for screen readers. Meanwhile, the highlight disappears from it. But we keep the highlighted person’s id and highlight them again if they’re in the list that replaces the old one.

During a search, we keep its controller in request and set it back to null in showResults() or showError(). Before picking or highlighting anyone in the keydown and click listeners, we call isStale().

picker.js
// picker.js
function isStale() {
	return request !== null;
}

Now try the finished picker on the same “Uneven” network. Click the field and wait for the suggestions. Type “marg” and press Enter right away, like in the first demo.

Isn’t that a lot better?

This time Enter does nothing while the list on screen shows the suggestions. Margaret shows up a moment later and stays, because the results for the shorter queries never come in. Press Down and Enter to assign her.

The point of the aborts is to stop those requests early, but the check on the request doesn’t depend on them. Even if you replace abort() with an empty function, the list stays on Margaret with the check alone.

Moving the highlight with the keyboard

To keep the highlight on a person, we store their id in the finished picker.

Option ids are built from it, like assignee-list-p03 for Margaret. The field’s aria-activedescendant gets that option id.

Back in the first version, option ids came from the row number. So as the older results came in, aria-activedescendant stayed on assignee-list-0, the id of Marcus’s option and later of Madison’s.

As new results come in, we use showResults() to keep the highlight on that person if they’re in the new list, even in another row. Otherwise we clear the highlight.

I don’t move the highlight to the first row, since Enter would pick whoever comes first in the newest results instead of the person you highlighted. I did that in the first version, like Angular Material does with its autoActiveFirstOption setting turned on.

picker.js
// picker.js
let results = [];
let highlightedId = null;

const optionId = ( personId ) => `assignee-list-${personId}`;

function showResults( people ) {
	request = null;
	results = people;
	if ( ! people.some( ( person ) => person.id === highlightedId ) ) {
		highlightedId = null;
	}
	drawOptions();
}

You can see the highlight follow a person in the second demo. Replace the text in its field with “ma” and wait for six people, press Down until Margaret is highlighted in the third row, and type “r”. She moves up to the second row, taking the highlight with her.

The APG pattern requires Down, Enter, and Escape on the field, but lists Up as optional. We handle all four here, though:

  • Down opens the list on the first person, or moves the highlight one person down.
  • Up opens the list on the last person, or moves the highlight one person up.
  • Enter assigns the highlighted person.
  • Escape closes the list until you type or press an arrow key, whatever results come in later.

Besides typing and the arrow keys, a click in the field reopens the list after Escape. On an empty field where the suggestions haven’t loaded yet, you press Down or Up one time to open them and a second time to highlight the first or the last person.

The list stays closed as you tab into the field. Otherwise, a list would pop up over the fields below it every time you tab through the form.

At the ends of the list, the pattern lets the highlight either go back to the field or stay put.

Down stops at the last person, because I didn’t want the highlight to wrap around to the top if the list grows under you. Up from the first person goes back to the field, where Enter does nothing.

The example in the APG wraps around from the last option to the first, but the pattern leaves that to you.

While the list is open, Enter doesn’t submit the form, even with no one highlighted. Once it’s closed, Enter submits the form as usual.

Enter also confirms the text you’re composing with an input method, and that Enter shouldn’t assign anyone.

Browsers set isComposing to true on keydown events during a composition. Safari can send the confirming Enter after compositionend, though, with isComposing already false. WebKit fixed that order and later switched the fix off again because of regression reports.

That Enter still has a keyCode of 229. Browsers give that code to any key an input method handled. MDN’s recommended check tests both, although keyCode is deprecated.

I put that check at the top of the handler, because the input method uses the arrow keys and Escape for its list of characters during a composition. Below, we use two helpers, isOpen() to check whether the list is showing and isStale() to keep Enter from picking anyone from an old list.

picker.js
// picker.js
$input.addEventListener( 'keydown', ( event ) => {
	if ( event.isComposing || event.keyCode === 229 ) {
		return;
	}
	if ( event.key === 'Enter' && isOpen() ) {
		event.preventDefault();
		const person = results.find( ( candidate ) => candidate.id === highlightedId );
		if ( person && ! isStale() ) {
			pick( person );
		}
	}
	// ArrowDown, ArrowUp, and Escape follow.
} );

When aria-activedescendant points at an option outside the visible part of the list, browsers don’t scroll it into view the way they do with a focused element. These demos don’t need it, since their server returns 10 people at most. That’s the most the Baymard Institute recommends showing on desktop.

On a longer list, we change the list’s scrollTop just enough to show the option. I skip scrollIntoView(), which with its default options also scrolls the page to put the option at the top of the window.

Your pointer moves the highlight too, through pointermove. As we redraw the list under a mouse you’re not moving, the browser fires mouseover and mouseenter on whoever is under the cursor now. A highlight that followed those events would jump to that person.

But the browser fires no pointermove for a pointer that hasn’t moved. Just in case a browser sends one anyway, we ignore a pointermove at the position of the previous one, and every pointermove on a dimmed list.

Picking a person and submitting the form

I keep the selected person, an object with an id and a name, apart from the results.

Margaret stays assigned if she’s missing from a later search, because the assignee comes from that object.

After you pick a person, we save them in pick() and write the name into the field and the id into the hidden input.

picker.js
// picker.js
let selected = null;

function pick( person ) {
	selected = person;
	$input.value = person.name;
	$hidden.value = person.id;
}

From then on, typing changes the query and leaves the person alone. So the field can show “da” while the form holds Margaret’s id.

By the time focus leaves the field, the two have to match again.

On focusout, we run settleField() to unassign the person if the field is empty at that point. Otherwise, we put the selected person’s name back in the field, or leave it empty if no one is selected.

picker.js
// picker.js
function settleField() {
	if ( $input.value.trim() === '' ) {
		selected = null;
		$hidden.value = '';
	}
	$input.value = selected?.name ?? '';
}

We run it on the form’s submit event as well. Enter submits the form even if the field holds typed text, as long as the list is closed. Since the submit event fires before the browser collects the form’s values, the field always shows the person the form sends.

No change listener on the form runs for a .value set from script, since the browser doesn’t fire an event for it. That’s why we dispatch a bubbling change event from the hidden input ourselves whenever the selection changes, even through a reset. I listen for that event in the demos’ task panel to add entries like “You assigned Marcus” to its “Activity” list.

In the last demo, the finished picker is part of the task’s form, with “Save” and “Cancel” buttons. At the bottom, a line shows the entries the form sends on “Save”, read from new FormData( form ). Some names in that demo’s code panel are different from the snippets here. For example, showResults() is called settle() there.

When the field already shows the assignee, a click selects their name and opens the suggestions. Whatever you type replaces the name instead of adding to it.

Pick Margaret, click her name in the field and type “da”, then click outside the field.

assignee-picker.ts
// index.ts
import type { Person, PickerOptions, PointerPosition, SearchPeople, SearchState } from './types';

export type { Person, PickerOptions, SearchPeople } from './types';

/**
 * How long a search runs before the list shows a loading line, in milliseconds.
 *
 * Most results arrive well before a second, and a shorter delay would flash the line on every keystroke.
 */
const LOADING_DELAY = 1000;

/**
 * Finds one required part of the picker's markup, typed without a cast.
 *
 * @param $root - The element that wraps the whole picker.
 * @param selector - The CSS selector for the part.
 * @param type - The element class the part must be an instance of.
 * @returns The part.
 */
function part<T extends Element>( $root: Element, selector: string, type: new () => T ): T {
	const $found = $root.querySelector( selector );
	if ( ! ( $found instanceof type ) ) {
		throw new Error( `The assignee picker needs ${selector} inside its root element.` );
	}
	return $found;
}

/**
 * Checks whether `showPopover()` takes a `source` option, which makes the field the popover's anchor.
 *
 * Some browser versions support `position-area` but not this option, and there the list would sit in a corner of the viewport.
 * The browser converts the options object before it checks the element, so the getter runs even though the call then throws on a `<div>` that isn't a popover.
 *
 * @returns Whether the getter ran.
 */
function showPopoverTakesSource(): boolean {
	let read = false;
	try {
		document.createElement( 'div' ).showPopover( {
			get source() {
				read = true;
				return undefined;
			},
		} );
	} catch {
		// The call always throws on a div without the popover attribute, and only the getter matters here.
	}
	return read;
}

/**
 * Builds the letters an avatar shows, one for a single name and two for a full name, from its first and last words.
 *
 * @param name - A full name.
 * @returns The initials, uppercased.
 */
function initials( name: string ): string {
	const words = name.trim().split( /\s+/ );
	const first = words.at( 0 )?.at( 0 ) ?? '';
	const last = words.length > 1 ? words.at( -1 )?.at( 0 ) ?? '' : '';
	return `${first}${last}`.toUpperCase();
}

/**
 * An assignee field that searches people on a server as the user types, and submits the picked person's id with its form.
 *
 * It enhances markup that already works as a labeled text field, so it needs no framework and no shadow root of its own.
 * The root element must hold an `<input role="combobox">` whose `aria-controls` names the listbox, an `<input type="hidden">` that carries the id, a `[popover]` element around a `.heading` and the `[role="listbox"]`, a `.message` with a `.message-text` and a `.retry` button inside the popover, and a `[role="status"]` element.
 *
 * Before the user types, the list offers suggestions, the results of a search for an empty query.
 * A click on the field, an arrow key, or clearing the text opens them, and moving focus into the field with Tab doesn't, so tabbing through the form never drops a list over the next fields.
 *
 * It keeps four values apart.
 * The text in the field is the query, which typing changes and a pick replaces with the person's name.
 * The highlight is a person's id, moved by the arrow keys and the pointer, and cleared when that person leaves the results.
 * The selected person is stored on its own, set by a pick, cleared when the field is left empty, and restored by a form reset.
 * The hidden input submits that person's id.
 *
 * While the search for the text in the field runs, the list keeps the older results on screen, dimmed and marked busy, and nothing in it can be picked.
 */
export class AssigneePicker {

	/**
	 * The element that wraps every part, used to tell whether focus is still inside the picker.
	 */
	private readonly $root: HTMLElement;

	/**
	 * The text field, which keeps focus the whole time so typing, the caret, and input methods keep working while the arrow keys move the highlight.
	 */
	private readonly $input: HTMLInputElement;

	/**
	 * The hidden input the form submits, holding the selected person's id or an empty string.
	 */
	private readonly $value: HTMLInputElement;

	/**
	 * The popover that holds the listbox and the message under it.
	 *
	 * It's `popover="manual"`, because an `auto` popover closes when the user clicks inside the field to move the caret.
	 */
	private readonly $popup: HTMLElement;

	/**
	 * The "Suggested" line above the options, shown only while the list holds the suggestions.
	 *
	 * It sits outside the listbox, which may hold nothing but options.
	 */
	private readonly $heading: HTMLElement;

	/**
	 * The listbox, which holds nothing but options, so the messages live beside it.
	 */
	private readonly $list: HTMLElement;

	/**
	 * The visible line under the options for loading, no matches, and errors.
	 */
	private readonly $message: HTMLElement;

	/**
	 * The text of that line, kept apart from the Retry button so setting it never removes the button.
	 */
	private readonly $messageText: HTMLElement;

	/**
	 * The button that runs the failed search again.
	 */
	private readonly $retry: HTMLButtonElement;

	/**
	 * The live region that announces how a search ended.
	 *
	 * It has to be in the page before its first message, because screen readers only announce changes to a live region that was already there.
	 */
	private readonly $status: HTMLElement;

	/**
	 * The search this picker runs for each query.
	 */
	private readonly search: SearchPeople;

	/**
	 * Whether this browser can anchor the popover to the field, which takes both `position-area` in CSS and the `source` option of `showPopover()`.
	 *
	 * Without them, the browser draws the popover in the top-left corner of the viewport, because the stylesheet sets its margin to 0.
	 * In that case the picker drops the popover and lets the stylesheet place the list under the field instead.
	 */
	private readonly anchored: boolean;

	/**
	 * Removes every listener the picker added, in one call from `destroy()`.
	 */
	private readonly listeners = new AbortController();

	/**
	 * The person the markup started with, which a form reset brings back.
	 *
	 * It's read once, because setting the hidden input's value from script also rewrites its default, and a reset would otherwise bring back the last pick.
	 */
	private readonly initial: Person | null;

	/**
	 * The person the form will submit, stored apart from the results, so a search that doesn't return them changes nothing about who is assigned.
	 */
	private selected: Person | null;

	/**
	 * The query whose results the list may show, trimmed, or null when no search has run since the list last closed for good.
	 *
	 * It changes when a search starts, and results for any other query are ignored.
	 * An empty string is a real query, the one that returns the suggestions, so null keeps "nothing searched yet" apart from it.
	 */
	private query: string | null = null;

	/**
	 * The people the list shows, the results for `query` or, while that search runs, the results of the search before it.
	 */
	private results: Person[] = [];

	/**
	 * The id of the highlighted person, or null.
	 *
	 * It's an id and not a position, so new results that move the person to another row keep the highlight on them, and results without them clear it.
	 */
	private highlightedId: string | null = null;

	/**
	 * Where the search for the current query stands.
	 */
	private state: SearchState = 'idle';

	/**
	 * The controller for the request still on its way, or null.
	 *
	 * Every query gets a new one, because an aborted signal stays aborted.
	 */
	private request: AbortController | null = null;

	/**
	 * The timer that shows the loading line once a search has run for a second.
	 */
	private loadingTimer: number | undefined;

	/**
	 * Whether an input method is composing text in the field, the time between `compositionstart` and `compositionend`.
	 */
	private composing = false;

	/**
	 * Whether the user closed the list with Escape, so results that arrive afterwards don't open it again.
	 *
	 * Typing, a click on the field, or an arrow key clears it.
	 */
	private dismissed = false;

	/**
	 * Where the pointer was at its last `pointermove`, so a move event with the same coordinates is ignored.
	 */
	private lastPointer: PointerPosition | null = null;

	/**
	 * Connects the picker to its markup and stores the person the field starts with.
	 *
	 * @param $root - The element that wraps every part of the picker.
	 * @param options - The search to run.
	 */
	constructor( $root: HTMLElement, options: PickerOptions ) {
		this.$root = $root;
		this.$input = part( $root, 'input[role="combobox"]', HTMLInputElement );
		this.$value = part( $root, 'input[type="hidden"]', HTMLInputElement );
		this.$popup = part( $root, '[popover]', HTMLElement );
		this.$heading = part( this.$popup, '.heading', HTMLElement );
		this.$list = part( this.$popup, '[role="listbox"]', HTMLElement );
		this.$message = part( this.$popup, '.message', HTMLElement );
		this.$messageText = part( this.$message, '.message-text', HTMLElement );
		this.$retry = part( this.$message, '.retry', HTMLButtonElement );
		this.$status = part( $root, '[role="status"]', HTMLElement );
		this.search = options.search;
		if ( this.$list.id === '' ) {
			throw new Error( 'The listbox needs an id, which aria-controls and the option ids are built from.' );
		}

		this.anchored = CSS.supports( 'position-area', 'block-end' ) && showPopoverTakesSource();
		if ( ! this.anchored ) {
			this.$popup.removeAttribute( 'popover' );
			this.$popup.hidden = true;
		}

		this.initial = this.$value.value === '' ? null : { id: this.$value.value, name: this.$input.value };
		this.selected = this.initial;

		const { signal } = this.listeners;
		this.$input.addEventListener( 'input', this.onInput, { signal } );
		this.$input.addEventListener( 'click', this.onFieldClick, { signal } );
		this.$input.addEventListener( 'compositionstart', this.onCompositionStart, { signal } );
		this.$input.addEventListener( 'compositionend', this.onCompositionEnd, { signal } );
		this.$input.addEventListener( 'keydown', this.onKeyDown, { signal } );
		this.$root.addEventListener( 'focusout', this.onFocusOut, { signal } );
		this.$popup.addEventListener( 'mousedown', this.keepFocus, { signal } );
		this.$list.addEventListener( 'click', this.onListClick, { signal } );
		this.$list.addEventListener( 'pointermove', this.onPointerMove, { signal } );
		this.$retry.addEventListener( 'click', this.onRetry, { signal } );
		this.$input.form?.addEventListener( 'reset', this.onReset, { signal } );
		this.$input.form?.addEventListener( 'submit', this.onSubmit, { signal } );
	}

	/**
	 * Removes every listener and stops the request still on its way, for when the field leaves the page.
	 */
	destroy(): void {
		this.listeners.abort();
		this.request?.abort();
		window.clearTimeout( this.loadingTimer );
	}

	/**
	 * Searches for what the user typed, unless an input method is still composing it.
	 */
	private onInput = (): void => {
		if ( this.composing ) {
			return;
		}
		this.dismissed = false;
		this.startSearch( this.$input.value );
	};

	/**
	 * Opens the list when the user clicks or taps the field.
	 *
	 * A click while the list is open only moves the caret, so it changes nothing.
	 */
	private onFieldClick = (): void => {
		if ( ! this.isOpen() ) {
			this.openList();
		}
	};

	/**
	 * Opens the list on what the field holds, after an Escape, a pick, or with nothing typed yet.
	 *
	 * When the field shows the assigned person's name, the list offers suggestions instead of searching for that name, and the text is selected so the first letter typed replaces it.
	 * A query that didn't change keeps the results it already has, so reopening after Escape sends no new request.
	 */
	private openList(): void {
		this.dismissed = false;
		const text = this.$input.value;
		if ( this.selected !== null && text === this.selected.name ) {
			this.$input.select();
			this.startSearch( '' );
		} else {
			this.startSearch( text );
		}
		this.sync();
	}

	/**
	 * Stops searching while an input method composes, since the text in the field isn't what the user means yet.
	 */
	private onCompositionStart = (): void => {
		this.composing = true;
	};

	/**
	 * Searches for the composed text once the input method commits it.
	 *
	 * Firefox sends one more `input` event after this one, and `startSearch()` skips it because the query didn't change.
	 */
	private onCompositionEnd = (): void => {
		this.composing = false;
		this.dismissed = false;
		this.startSearch( this.$input.value );
	};

	/**
	 * Starts the search for a query, after stopping the one the user left.
	 *
	 * The results on screen stay until the results for the new query arrive, so the list never empties between keystrokes.
	 * Until then `isStale()` is true, and nothing in the list can be picked.
	 * An empty query takes the same path as any other, so suggestions that arrive after the user typed are ignored like the results of any older search.
	 *
	 * @param text - The text in the field.
	 * @param again - Whether to search even though the query didn't change, which only Retry does.
	 */
	private startSearch( text: string, again = false ): void {
		const query = text.trim();
		if ( query === this.query && ! again ) {
			return;
		}
		this.query = query;
		this.request?.abort();
		this.request = null;
		window.clearTimeout( this.loadingTimer );
		this.$status.textContent = '';

		const request = new AbortController();
		this.request = request;
		this.state = 'pending';
		this.loadingTimer = window.setTimeout( () => {
			if ( this.request === request ) {
				this.state = 'slow';
				this.sync();
			}
		}, LOADING_DELAY );
		this.sync();
		void this.load( query, request );
	}

	/**
	 * Runs one search and shows its results, but only while it's still the latest request.
	 *
	 * Aborting stops the request, but it can't undo results that were already parsed, and some searches take no signal at all.
	 * The check on the request right before `settle()` covers every case, even two requests for the same text, like "ma" before and after "mar".
	 * The catch block tests the request's own `signal.aborted`, because engines reject an aborted `fetch()` with different errors.
	 *
	 * @param query - The query to search for.
	 * @param request - The controller whose signal stops it.
	 */
	private async load( query: string, request: AbortController ): Promise<void> {
		try {
			const people = await this.search( query, { signal: request.signal } );
			if ( request !== this.request ) {
				return;
			}
			this.settle( people );
		} catch {
			if ( request.signal.aborted || request !== this.request ) {
				return;
			}
			this.fail();
		}
	}

	/**
	 * Shows the results for the current query and announces how many people they hold.
	 *
	 * The highlight stays on its person if they're still in the list and is cleared otherwise.
	 * It never falls back to the first row, because new results would then put someone the user never chose under Enter.
	 *
	 * @param people - The results.
	 */
	private settle( people: Person[] ): void {
		window.clearTimeout( this.loadingTimer );
		this.request = null;
		this.results = people;
		if ( ! people.some( ( person ) => person.id === this.highlightedId ) ) {
			this.highlightedId = null;
		}
		this.state = people.length === 0 ? 'empty' : 'done';
		this.$status.textContent = this.countMessage( people.length );
		this.drawOptions();
		this.sync();
	}

	/**
	 * Shows the error for a search that failed, and clears the list, since the people in it are results for an older query.
	 */
	private fail(): void {
		window.clearTimeout( this.loadingTimer );
		this.request = null;
		this.results = [];
		this.highlightedId = null;
		this.state = 'failed';
		this.$status.textContent = 'Couldn\'t load people';
		this.drawOptions();
		this.sync();
	}

	/**
	 * Builds the announcement for a finished search.
	 *
	 * Suggestions are announced as suggested people, since nothing typed explains where the list came from.
	 *
	 * @param count - How many people it found.
	 * @returns The message for the status region, empty when there are no suggestions to offer.
	 */
	private countMessage( count: number ): string {
		if ( this.query === '' ) {
			if ( count === 0 ) {
				return '';
			}
			return count === 1 ? '1 suggested person' : `${count} suggested people`;
		}
		if ( count === 0 ) {
			return `No one matches ${this.query}`;
		}
		return count === 1 ? '1 person' : `${count} people`;
	}

	/**
	 * Builds the DOM id of a person's option from the listbox id and the person's id.
	 *
	 * `aria-activedescendant` then names the person, so the same id never points at someone else after the list changes.
	 *
	 * @param personId - The person's id.
	 * @returns The option's id.
	 */
	private optionId( personId: string ): string {
		return `${this.$list.id}-${personId}`;
	}

	/**
	 * Draws one option per result, each with an initials avatar and the person's name.
	 *
	 * An empty listbox is hidden, so the popover can still show a message with no options above it.
	 * Suggestions get the "Suggested" line above them and a listbox label that says so, because the list isn't the results for anything the user typed.
	 */
	private drawOptions(): void {
		const $options = this.results.map( ( person ) => {
			const $option = document.createElement( 'li' );
			$option.id = this.optionId( person.id );
			$option.setAttribute( 'role', 'option' );
			$option.setAttribute( 'aria-selected', 'false' );
			$option.dataset.person = person.id;
			const $avatar = document.createElement( 'span' );
			$avatar.className = 'avatar';
			$avatar.setAttribute( 'aria-hidden', 'true' );
			$avatar.textContent = initials( person.name );
			const $name = document.createElement( 'span' );
			$name.className = 'name';
			$name.textContent = person.name;
			$option.append( $avatar, $name );
			return $option;
		} );
		this.$list.replaceChildren( ...$options );
		this.$list.hidden = $options.length === 0;
		const suggested = this.query === '' && $options.length > 0;
		this.$heading.hidden = ! suggested;
		this.$list.setAttribute( 'aria-label', suggested ? 'Suggested people' : 'People' );
	}

	/**
	 * Builds the text of the visible line under the options for the current state, or an empty string when there's nothing to say.
	 *
	 * "No one matches" shows only once the results are in, since people read a missing suggestion as the person not existing.
	 * Empty suggestions have nothing to report, so the list stays closed.
	 *
	 * @returns The text of the line.
	 */
	private messageText(): string {
		switch ( this.state ) {
			case 'slow':
				return this.query === '' ? 'Loading suggestions' : `Searching for ${this.query}`;
			case 'empty':
				return this.query === '' ? '' : `No one matches ${this.query}`;
			case 'failed':
				return 'Couldn\'t load people';
			default:
				return '';
		}
	}

	/**
	 * Checks whether the list on screen shows the results of an older search than the text in the field, the suggestions included.
	 *
	 * That's true from the moment a search starts until its results or its error arrive.
	 * During that time the arrow keys, the pointer, Enter, and a click pick no one, and the listbox is marked `aria-busy` and dimmed.
	 * Select2 and GitHub's `<auto-complete>` element both pick from the old list when Enter comes before the new results.
	 *
	 * @returns Whether the search for the current query is still running.
	 */
	private isStale(): boolean {
		return this.state === 'pending' || this.state === 'slow';
	}

	/**
	 * Brings the popover, the message, the expanded state, the busy state, and the highlight in line with the picker's values.
	 *
	 * The popover opens only while focus is in the picker and the user hasn't dismissed it.
	 * While the list shows older results, no option is marked as highlighted, but the highlighted id stays, so the highlight comes back if that person is in the new results.
	 *
	 * @param scroll - Whether to scroll the highlighted option into view, which only `move()` sets.
	 */
	private sync( scroll = false ): void {
		const message = this.messageText();
		this.$messageText.textContent = message;
		this.$message.hidden = message === '';
		this.$retry.hidden = this.state !== 'failed';

		const open = ( this.results.length > 0 || message !== '' ) && ! this.dismissed && this.hasFocus();
		this.setOpen( open );
		if ( ! open ) {
			this.highlightedId = null;
		}
		this.$input.setAttribute( 'aria-expanded', String( open && this.results.length > 0 ) );

		const stale = this.isStale();
		this.$list.setAttribute( 'aria-busy', String( stale ) );
		let $active: HTMLElement | null = null;
		for ( const $option of this.$list.children ) {
			if ( $option instanceof HTMLElement ) {
				const active = ! stale && $option.dataset.person === this.highlightedId;
				$option.setAttribute( 'aria-selected', String( active ) );
				if ( active ) {
					$active = $option;
				}
			}
		}
		if ( $active ) {
			this.$input.setAttribute( 'aria-activedescendant', $active.id );
			if ( scroll ) {
				this.keepVisible( $active );
			}
		} else {
			this.$input.removeAttribute( 'aria-activedescendant' );
		}
	}

	/**
	 * Scrolls the list just enough to show the highlighted option.
	 *
	 * Browsers don't scroll an active descendant into view, and `scrollIntoView()` would scroll the page too, so the list's own `scrollTop` moves.
	 *
	 * @param $option - The highlighted option.
	 */
	private keepVisible( $option: HTMLElement ): void {
		const list = this.$list.getBoundingClientRect();
		const option = $option.getBoundingClientRect();
		if ( option.top < list.top ) {
			this.$list.scrollTop -= list.top - option.top;
		} else if ( option.bottom > list.bottom ) {
			this.$list.scrollTop += option.bottom - list.bottom;
		}
	}

	/**
	 * Checks whether focus is on the field or on another part of the picker, like the Retry button.
	 *
	 * @returns Whether the picker holds focus.
	 */
	private hasFocus(): boolean {
		const tree = this.$root.getRootNode();
		if ( tree instanceof Document || tree instanceof ShadowRoot ) {
			return tree.activeElement !== null && this.$root.contains( tree.activeElement );
		}
		return false;
	}

	/**
	 * Checks whether the list is showing.
	 *
	 * @returns Whether the popover, or the list that stands in for it, is open.
	 */
	private isOpen(): boolean {
		return this.anchored ? this.$popup.matches( ':popover-open' ) : ! this.$popup.hidden;
	}

	/**
	 * Opens or closes the list.
	 *
	 * `source` makes the field the popover's anchor, which the stylesheet's `position-area` places the list against.
	 *
	 * @param open - Whether the list should show.
	 */
	private setOpen( open: boolean ): void {
		if ( open === this.isOpen() ) {
			return;
		}
		if ( ! this.anchored ) {
			this.$popup.hidden = ! open;
		} else if ( open ) {
			this.$popup.showPopover( { source: this.$input } );
		} else {
			this.$popup.hidePopover();
		}
	}

	/**
	 * Handles the keys of the combobox pattern while focus stays in the field.
	 *
	 * Safari can send the Enter that confirms a composition after `compositionend`, with `isComposing` false and `keyCode` 229, so both are checked.
	 * Down and Up on a closed list open it first, on suggestions when nothing is typed, and they highlight a person only once the results for the current query are in.
	 * Enter never submits the form while the list is open, and it picks no one when nothing is highlighted or the list shows older results.
	 *
	 * @param event - The field's `keydown` event.
	 */
	private onKeyDown = ( event: KeyboardEvent ): void => {
		if ( event.isComposing || event.keyCode === 229 ) {
			return;
		}
		switch ( event.key ) {
			case 'ArrowDown':
				event.preventDefault();
				if ( ! this.isOpen() ) {
					this.openList();
				}
				this.move( 1 );
				break;
			case 'ArrowUp':
				event.preventDefault();
				if ( ! this.isOpen() ) {
					this.openList();
				}
				this.move( -1 );
				break;
			case 'Enter':
				if ( this.isOpen() ) {
					event.preventDefault();
					const person = this.results.find( ( candidate ) => candidate.id === this.highlightedId );
					if ( person && ! this.isStale() ) {
						this.pick( person );
					}
				}
				break;
			case 'Escape':
				if ( this.isOpen() ) {
					event.preventDefault();
					this.dismissed = true;
					this.sync();
				}
				break;
		}
	};

	/**
	 * Moves the highlight one person down or up.
	 *
	 * Down from the field opens the list on the first person, and Down on the last person stays there instead of wrapping, so a list that grows under the user never jumps them back to the top.
	 * Up from the field goes to the last person, and Up on the first person returns to the field with nothing highlighted, where Enter does nothing.
	 * While the list shows older results, it doesn't move.
	 *
	 * @param step - 1 for Down, -1 for Up.
	 */
	private move( step: 1 | -1 ): void {
		if ( this.results.length === 0 || this.isStale() ) {
			return;
		}
		if ( ! this.isOpen() ) {
			this.sync();
		}
		const last = this.results.length - 1;
		const index = this.results.findIndex( ( person ) => person.id === this.highlightedId );
		let next: number;
		if ( step === 1 ) {
			next = index === -1 ? 0 : Math.min( index + 1, last );
		} else {
			next = index === -1 ? last : index - 1;
		}
		this.highlightedId = next < 0 ? null : this.results[next]?.id ?? null;
		this.sync( true );
	}

	/**
	 * Assigns a person, writing their name into the field and their id into the hidden input.
	 *
	 * The list closes and the search state starts over, and typing afterwards searches again without touching the selection.
	 * The browser fires no event for a value set from script, and the hidden input dispatches `change` itself for any code that watches the form.
	 *
	 * @param person - The person to assign.
	 */
	private pick( person: Person ): void {
		this.selected = { id: person.id, name: person.name };
		this.$input.value = person.name;
		this.$value.value = person.id;
		this.forget();
		this.$value.dispatchEvent( new Event( 'change', { bubbles: true } ) );
	}

	/**
	 * Drops the query, the results, and the request on its way, and closes the list.
	 */
	private forget(): void {
		this.request?.abort();
		this.request = null;
		window.clearTimeout( this.loadingTimer );
		this.query = null;
		this.results = [];
		this.highlightedId = null;
		this.state = 'idle';
		this.dismissed = false;
		this.$status.textContent = '';
		this.drawOptions();
		this.sync();
	}

	/**
	 * Keeps focus in the field when the user presses on the list or its message, which would otherwise blur the field and close the list before the click event fires.
	 *
	 * @param event - The popover's `mousedown` event.
	 */
	private keepFocus = ( event: MouseEvent ): void => {
		event.preventDefault();
	};

	/**
	 * Assigns the person whose option was clicked, unless the list shows older results.
	 *
	 * @param event - The listbox's `click` event.
	 */
	private onListClick = ( event: MouseEvent ): void => {
		if ( this.isStale() ) {
			return;
		}
		const person = this.personAt( event.target );
		if ( person ) {
			this.pick( person );
		}
	};

	/**
	 * Highlights the person under the pointer.
	 *
	 * It listens for `pointermove` and not `mouseover` or `mouseenter`, because those fire when the list changes under a mouse that isn't moving.
	 * A move with the same coordinates as the last one is ignored for the same reason, and so is any move while the list shows older results.
	 *
	 * @param event - The listbox's `pointermove` event.
	 */
	private onPointerMove = ( event: PointerEvent ): void => {
		if ( this.lastPointer?.x === event.clientX && this.lastPointer.y === event.clientY ) {
			return;
		}
		this.lastPointer = { x: event.clientX, y: event.clientY };
		if ( this.isStale() ) {
			return;
		}
		const person = this.personAt( event.target );
		if ( person && person.id !== this.highlightedId ) {
			this.highlightedId = person.id;
			this.sync();
		}
	};

	/**
	 * Finds the person whose option holds an event's target.
	 *
	 * @param target - The element the event happened on.
	 * @returns The person, or undefined outside the options.
	 */
	private personAt( target: EventTarget | null ): Person | undefined {
		const $option = target instanceof Element ? target.closest( '[role="option"]' ) : null;
		if ( ! ( $option instanceof HTMLElement ) ) {
			return undefined;
		}
		return this.results.find( ( person ) => person.id === $option.dataset.person );
	}

	/**
	 * Runs the failed search again and puts focus back in the field.
	 *
	 * It searches for the failed query again.
	 * When the suggestions failed while the field showed the assigned person's name, it searches for the empty query the suggestions use, since the name was never searched.
	 */
	private onRetry = (): void => {
		this.$input.focus();
		this.startSearch( this.query ?? this.$input.value, true );
	};

	/**
	 * Puts the field back in line with the selection.
	 *
	 * An empty field unassigns the person.
	 * Any other text goes back to the selected person's name, or to an empty field when nobody is selected, so the field never shows a name the form won't submit.
	 */
	private settleField(): void {
		if ( this.$input.value.trim() === '' && this.selected !== null ) {
			this.selected = null;
			this.$value.value = '';
			this.$value.dispatchEvent( new Event( 'change', { bubbles: true } ) );
		}
		this.$input.value = this.selected?.name ?? '';
		this.forget();
	}

	/**
	 * Settles the field once focus leaves the picker.
	 *
	 * @param event - The `focusout` event from any part of the picker.
	 */
	private onFocusOut = ( event: FocusEvent ): void => {
		if ( event.relatedTarget instanceof Node && this.$root.contains( event.relatedTarget ) ) {
			return;
		}
		this.settleField();
	};

	/**
	 * Settles the field when the form submits.
	 *
	 * Enter submits the form once the list is closed, while the field may still hold the text the user typed.
	 * The `submit` event fires before the form collects its values, so an emptied field is already unassigned when the hidden input is read.
	 */
	private onSubmit = (): void => {
		this.settleField();
	};

	/**
	 * Brings back the person the field started with when the form resets.
	 *
	 * The `reset` event fires before the form resets its controls, so the picker sets a zero-delay timer and restores the initial person in it.
	 * It can't rely on the hidden input's own reset, because setting `.value` on a hidden input rewrites its default too.
	 */
	private onReset = (): void => {
		window.setTimeout( () => {
			this.selected = this.initial;
			this.$input.value = this.initial?.name ?? '';
			this.$value.value = this.initial?.id ?? '';
			this.forget();
			this.$value.dispatchEvent( new Event( 'change', { bubbles: true } ) );
		}, 0 );
	};
}
The panel shows the code of the picker in this field. The task panel and the network menu around it are only there for the demo.

Margaret is back in the field, matching the assignee=p03 the form keeps sending. No new entry shows up in the “Activity” list.

“Cancel” resets the form.

When a script sets a hidden input’s .value, the browser rewrites the input’s default value as well, the value a reset goes back to. Ryan is the task’s original assignee. A plain reset would put his name back in the text field and keep Margaret’s id in the hidden input.

You can’t fix that on the spot in a reset listener, because the event fires before the controls reset. We use setTimeout() in the picker’s reset listener to restore the starting person we saved from the markup once the reset is done.

The messages about a search go in two places, one for screen readers and one on screen.

Screen readers get the messages through the role="status" paragraph from the markup. WCAG (the Web Content Accessibility Guidelines) treats a result count or a “no results” line as a status message, which screen readers announce without moving focus. Search results themselves don’t count.

Every search ends with a message like “3 people” or “No one matches zoe” in that paragraph. For the suggestions it says “5 suggested people”. With nothing typed, “5 people” wouldn’t say where that list came from. Results we dropped in the check aren’t announced.

Since we empty the paragraph as each search starts, a second “3 people” in a row still changes its text.

The count or the “No one matches” line comes from announce(). That happens at the end of showResults(). Only the results that pass the check get that far.

picker.js
// picker.js
function announce( people ) {
	const count = people.length;
	const noun = count === 1 ? 'person' : 'people';
	if ( currentQuery === '' ) {
		$status.textContent = count === 0 ? '' : `${count} suggested ${noun}`;
	} else if ( count === 0 ) {
		$status.textContent = `No one matches ${currentQuery}`;
	} else {
		$status.textContent = `${count} ${noun}`;
	}
}

For a failed search, we put “Couldn’t load people” there with showError().

Sighted users get a line under the options, inside the pop-up box. “No one matches zoe” shows up only after the results are in. In the meantime, the old list stays on screen.

Nielsen Norman Group warns that if people type the first few letters of something and it isn’t suggested, some of them might conclude the site doesn’t offer it. If that line showed up too early, people could think the person they want is missing from the server.

For a search that takes longer than about a second, a loading line shows up, like “Searching for mar” or “Loading suggestions”. Shorter delays don’t need any special feedback, least of all a line that flashes between keystrokes.

Failed searches show “Couldn’t load people” with a “Retry” button. Pick “Slow” in the last demo’s “Network” menu to see the loading line, or “Offline” for the error.

None of these lines go inside the listbox. They go under it, with the “Suggested” heading above it. A listbox may contain only options and groups of options.

The listbox’s aria-label changes from “People” to “Suggested people” for the suggestions.

Showing the list under the input

Once it’s open, the list covers part of the task, under the field.

We put it in a popover and place it with CSS anchor positioning. I already explained both in my tooltip article, like how the browser shows a popover in the top layer, above everything else on the page, and flips it to the other side of its anchor if it doesn’t fit. But a picker needs a few changes.

I used the default auto popover in the tooltip, which closes on a click outside it. In a picker, that includes a click in the field to move the caret.

Our list is a popover="manual". Manual popovers stay open until we hide them.

html
<div class="popup" popover="manual">
	<ul id="assignee-list" role="listbox" aria-label="People"></ul>
	<p class="message"></p>
</div>

To place several tooltips on a page, I had to give them unique anchor names, for anchor-name and position-anchor. Here we skip the names and open the list with $popup.showPopover( { source: $input } ), using the source option to make the field the popover’s anchor.

We use position-area in the CSS to place the list under the field, lined up with its start edge and free to grow past its end. Its min-width is anchor-size( width ), the field’s own width.

By default, browsers center a popover in the window with margin: auto. Some browser versions that support anchoring still apply that margin inside the area under the field. MDN recommends setting margin: 0 to keep the list against the field.

picker.css
/* picker.css */
.popup {
  position-area: block-end span-inline-end;
  position-try-fallbacks: flip-block;
  min-width: anchor-size( width );
  margin: 0;
}

Without anchor positioning, the browser ignores the anchor properties, and with margin: 0 it draws the list in the top-left corner of the window. Browser versions that support position-area but not the source option put the list in that same corner. At startup, we check for both. Where either one is missing, we remove the popover attribute and position the list absolutely in the stylesheet, under the field.

The fallback isn’t perfect, though. Outside the top layer, that list can be clipped by any ancestor with overflow: hidden.

Checking your library

If your picker comes from a component library, run this test on it.

Use a mock or your development server to make “m” take about 2.4 seconds, “ma” 1.8, and “mar” 0.2. Type “mar” one letter at a time, about a third of a second apart. Press Down to the second person and wait for the older results before you press Enter. That way, your typing speed can’t change the result.

I ran it on Angular Material and on three React libraries, React Aria, Downshift, and Headless UI. React Aria’s docs have a recipe for server results. I used the type-ahead pattern from Angular’s docs for Angular Material. I took the basic examples from Downshift and Headless UI and replaced their local filter with a server call.

I also swapped the options under the highlight without any network involved, typed an “x” at the end of Margaret’s name and left the field, and pressed Enter in the middle of a composition.

Table What four libraries and this article’s picker do in each part of the test.
PickerOptions change under the highlightOlder results with the docs’ setupText after typing “x” and leavingEnter while composing
Angular MaterialHighlight cleared, or moved to the first person with autoActiveFirstOptionCanceled by switchMap, Margaret assignedBecomes the value, “Margaretx”Ignored
React AriaHighlight follows the person, Margaret assignedAborted by useAsyncList, Margaret assignedField goes blank, Margaret still selectedIgnored
DownshiftHighlight stays on row 2, Marcus assignedStale list shows, Marcus assignedStays “Margaretx”, Margaret still selectedIgnored
Headless UIHighlight follows the person, Margaret assignedStale list shows, Margaret still assignedBack to “Margaret”Ignored
This article’s pickerHighlight follows the person, or clears when they leaveAborted and ignored, Margaret assignedBack to “Margaret”, form sends p03Ignored, Safari’s order included

Angular Material clears its highlight whenever the options change, keeping older results from making Enter pick anyone as long as autoActiveFirstOption stays off. Cancel the older requests with switchMap. Newer code can use httpResource() to cancel them as well. With requireSelection on, Angular Material never lets typed text become the value and clears both the text and the assignee if you leave the field without a pick.

React Aria’s useAsyncList aborts the older requests and ignores their responses. If the field shouldn’t go blank, keep the selected person’s name yourself.

Downshift stores the highlight as a number. Guard the fetch yourself and control highlightedIndex to move the highlight with the person as the items change.

Headless UI leaves the fetch to you as well. Guard it with React’s ignore flag and key the options by the person’s id.

A few ways this goes wrong

The required attribute has no effect on a hidden input. The form submits an empty assignee without a message, as hidden inputs are barred from constraint validation. Validate the visible field with setCustomValidity() to get the browser’s usual message on it.

If all the searches share one AbortController, you stop getting results from the second letter on. Any later fetch() that gets an aborted signal rejects immediately, as the signal stays aborted. Create a new controller per query, the way we do in startSearch().

With a list that closes on blur, a click on an option does nothing. As you press the mouse button on an option, the browser moves focus out of the field ahead of the click, by which point the list is gone. Here we call preventDefault() on the list’s mousedown to keep focus on the field.

A list moved to the end of <body> to escape overflow: hidden stops working inside a modal <dialog>. Everything outside an open modal dialog is inert, including the people in the list. A popover that stays inside the dialog in the DOM goes to the top layer anyway and keeps getting the clicks.

What separates a senior from a junior here

  • On a fast network, the results usually come in order and the list rarely jumps. An experienced reviewer slows the short queries down on purpose and highlights a person with the older results still loading.
  • An abort() in the diff looks like the whole fix. Look for the check on the request right where the results are written, then test it with an empty abort().
  • results[activeIndex] next to Enter means Enter picks a position. Store the highlight as a person’s id, and build the option ids and the list keys from it as well.
  • The field can show one person while the form sends another. Leave the field and check what new FormData( form ) holds, and do the same with a reset.

Want more of these in your Google results?