Components

Listbox

A visible set of options from which people choose one or several.

Markdown

A listbox presents a set of options from which people choose one or several. The options remain visible, so the range of choices and the current choice are apparent at a glance.

Multiple selection

Click, Command-click and Shift-click, or press a file that is not selected and drag: past the edge the list scrolls. Shift with the arrow keys extends; Command-A selects all; Return or a double click opens. By touch, a tap opens while nothing is selected; a long press begins a selection, and a finger kept down draws a range.

Agenda.mdMarkdown4 KB
Brief.pdfPDF document1.2 MB
Budget 2027.numbersSpreadsheet384 KB
Contract.pdfPDF document220 KB
Cover.pngPNG image2.8 MB
Floor plan.svgSVG image64 KB
Interview notes.mdMarkdown12 KB
Invoice 0412.pdfPDF document88 KB
Logo.svgSVG image9 KB
Moodboard.figDesign file18.4 MB
Photo 01.jpgJPEG image3.1 MB
Photo 02.jpgJPEG image2.9 MB
Proposal.docxWord document96 KB
Recording.m4aAudio24.1 MB
Storyboard.keyPresentation41.0 MB
Timeline.numbersSpreadsheet152 KB

1 of 16 selected

Code
"use client";

import { Listbox, ListboxItem } from "@publictheta/ui/components/listbox/listbox.tsx";
import { useState } from "react";

const FILES = [
  { name: "Agenda.md", kind: "Markdown", size: "4 KB" },
  { name: "Brief.pdf", kind: "PDF document", size: "1.2 MB" },
  { name: "Budget 2027.numbers", kind: "Spreadsheet", size: "384 KB" },
  { name: "Contract.pdf", kind: "PDF document", size: "220 KB" },
  { name: "Cover.png", kind: "PNG image", size: "2.8 MB" },
  { name: "Floor plan.svg", kind: "SVG image", size: "64 KB" },
  { name: "Interview notes.md", kind: "Markdown", size: "12 KB" },
  { name: "Invoice 0412.pdf", kind: "PDF document", size: "88 KB" },
  { name: "Logo.svg", kind: "SVG image", size: "9 KB" },
  { name: "Moodboard.fig", kind: "Design file", size: "18.4 MB" },
  { name: "Photo 01.jpg", kind: "JPEG image", size: "3.1 MB" },
  { name: "Photo 02.jpg", kind: "JPEG image", size: "2.9 MB" },
  { name: "Proposal.docx", kind: "Word document", size: "96 KB" },
  { name: "Recording.m4a", kind: "Audio", size: "24.1 MB" },
  { name: "Storyboard.key", kind: "Presentation", size: "41.0 MB" },
  { name: "Timeline.numbers", kind: "Spreadsheet", size: "152 KB" },
];

// Secondary text stays readable on both selection colours.
const SECONDARY = "text-xs text-fg-muted group-focus-within/listbox:in-data-selected:text-on-selected";

export default function FileSelection() {
  const [selected, setSelected] = useState<string[]>(["Cover.png"]);
  const [opened, setOpened] = useState<string | null>(null);
  const [filter, setFilter] = useState("");
  const shown = FILES.filter((file) => file.name.toLowerCase().includes(filter.trim().toLowerCase()));

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <input
        type="search"
        aria-label="Filter files"
        placeholder="Filter"
        value={filter}
        onChange={(event) => setFilter(event.target.value)}
        className="h-8 rounded-md border border-border bg-surface px-2 text-sm text-fg-strong outline-none focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-focus-ring focus-visible:outline-solid pointer-coarse:h-11 pointer-coarse:text-base"
      />
      <Listbox
        aria-label="Files"
        selectionMode="multiple"
        value={selected}
        onValueChange={setSelected}
        onAction={setOpened}
        emptyState="No files match the filter."
        className="h-72"
      >
        {shown.map((file) => (
          <ListboxItem key={file.name} value={file.name} textValue={file.name}>
            <span className="flex-1 truncate">{file.name}</span>
            <span className={`w-28 truncate ${SECONDARY}`}>{file.kind}</span>
            <span className={`w-16 text-right tabular-nums ${SECONDARY}`}>{file.size}</span>
          </ListboxItem>
        ))}
      </Listbox>
      <p className="text-sm text-fg-muted" aria-live="polite">
        {selected.length === 0 ? "No items selected" : `${selected.length} of ${FILES.length} selected`}
        {opened !== null && ` ยท Opened ${opened}`}
      </p>
    </div>
  );
}

Ten thousand options

Add the rows: every one is in the document. Hold an arrow key, drag past the edge, press End with Shift held: only the options that change are drawn again.

0 of 0 selected

Code
"use client";

import { Listbox, ListboxItem } from "@publictheta/ui/components/listbox/listbox.tsx";
import { useState } from "react";
import type { ReactElement } from "react";

const COUNT = 10_000;
const formatter = new Intl.NumberFormat("en");

// Made once, and only when asked for: ten thousand options are not worth
// sending in the page. An owner that renders again on every change hands the
// list the same options, and only those whose state changed are drawn again.
function rows(): ReactElement[] {
  return Array.from({ length: COUNT }, (_, index) => {
    const name = `Row ${formatter.format(index + 1)}`;
    return (
      <ListboxItem key={name} value={name}>
        {name}
      </ListboxItem>
    );
  });
}

export default function ManyOptions() {
  const [options, setOptions] = useState<ReactElement[]>([]);
  const [selected, setSelected] = useState<string[]>([]);

  return (
    <div className="flex w-64 flex-col gap-3">
      <Listbox
        aria-label="Rows"
        selectionMode="multiple"
        value={selected}
        onValueChange={setSelected}
        emptyState="No rows yet."
        className="h-72"
      >
        {options}
      </Listbox>
      <div className="flex items-center justify-between gap-3">
        <p className="text-sm text-fg-muted tabular-nums" aria-live="polite">
          {formatter.format(selected.length)} of {formatter.format(options.length)} selected
        </p>
        <button
          type="button"
          disabled={options.length > 0}
          onClick={() => setOptions(rows())}
          className="h-8 rounded-md border border-border bg-surface px-3 text-sm text-fg-strong outline-none select-none enabled:hover:bg-surface-muted disabled:text-fg-disabled focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring focus-visible:outline-solid pointer-coarse:h-11 pointer-coarse:text-base"
        >
          Add {formatter.format(COUNT)} rows
        </button>
      </div>
    </div>
  );
}

Single selection

Selection follows focus. Type a name to move to it.

Auckland
Honolulu
Lisbon
London
Los Angeles
Mumbai
New York
Tokyo

Selected: Lisbon

Code
"use client";

import { Listbox, ListboxItem } from "@publictheta/ui/components/listbox/listbox.tsx";
import { useState } from "react";

const TIMEZONES = ["Auckland", "Honolulu", "Lisbon", "London", "Los Angeles", "Mumbai", "New York", "Tokyo"];

export default function SingleSelection() {
  const [zone, setZone] = useState<string | null>("Lisbon");
  return (
    <div className="flex w-64 flex-col gap-3">
      <Listbox aria-label="Time zone" value={zone} onValueChange={setZone} className="max-h-64">
        {TIMEZONES.map((name) => (
          <ListboxItem key={name} value={name}>
            {name}
          </ListboxItem>
        ))}
      </Listbox>
      <p className="text-sm text-fg-muted">Selected: {zone ?? "none"}</p>
    </div>
  );
}

Groups

Groups are named regions of one sequence; a disabled option is passed over.

Inter
Helvetica Neue
SF Pro
Charter
Iowan Old Style
JetBrains Mono
Code
import { Listbox, ListboxGroup, ListboxItem } from "@publictheta/ui/components/listbox/listbox.tsx";

export default function Groups() {
  return (
    <Listbox aria-label="Font" defaultValue="Inter" className="w-56">
      <ListboxGroup label="Sans serif">
        <ListboxItem value="Inter">Inter</ListboxItem>
        <ListboxItem value="Helvetica Neue">Helvetica Neue</ListboxItem>
        <ListboxItem value="SF Pro" disabled>
          SF Pro
        </ListboxItem>
      </ListboxGroup>
      <ListboxGroup label="Serif">
        <ListboxItem value="Charter">Charter</ListboxItem>
        <ListboxItem value="Iowan Old Style">Iowan Old Style</ListboxItem>
      </ListboxGroup>
      <ListboxGroup label="Monospace">
        <ListboxItem value="JetBrains Mono">JetBrains Mono</ListboxItem>
      </ListboxGroup>
    </Listbox>
  );
}

When to use

Use a listbox when seeing the options informs the choice, or when people choose several. When space is limited and only the result matters, a popup button or a menu conserves room. For a choice among a few options, radio buttons or a segmented control are more direct.

Best practices

Name the list. Every listbox needs an accessible name that says what is being chosen, even where a visible heading makes it evident.

Order options meaningfully โ€” alphabetically, by recency or by frequency โ€” and keep the order stable while people are choosing.

Begin each label with the word that distinguishes it. People scan the start of a label, and typeahead matches from it.

Group related options under short headings when a list is long or its options fall into kinds. Groups remain one sequence: the arrow keys pass from one group to the next.

Show the extent of a multiple selection when the list scrolls. A count beside the list keeps selected options that are out of view from being forgotten.

Prefer disabling to removing an option that is temporarily unavailable, when its presence helps people understand what the list offers.

Distinguish choosing from acting. Selecting an option chooses it. Opening it or carrying out its action is separate: Return (Enter) or a double click. A key held down acts once.

Keep what was selected. An option that is selected but unavailable, or not shown at the moment, stays selected while others are chosen around it.

Keep focus in the list. When the option that has focus goes away โ€” removed, or made unavailable โ€” focus moves to the option that takes its place, not out of the list.

Say when there is nothing to choose. A list with no options โ€” filtered to nothing, or not yet filled โ€” shows a short statement of that in their place, and gives it as the list's description, so that it is heard when the list takes focus. The statement is words, not controls.

Give each option its own value. Options that share a value are selected together and cannot be told apart; the arrow keys still pass through each.

Stay immediate however long the list. With thousands of options, a key press changes only the options it concerns: the one focus left, the one it reached, those whose selection changed. Reading every option's text, or drawing every option again, on each key is felt long before the list is long.

Platform considerations

Pointer and keyboard. A listbox follows the conventions of Selection, Keyboard navigation and Typeahead. Page Up and Page Down move by as many options as the list shows at once, along whichever way the list runs. A query being typed ends when focus leaves the list.

Dragging. In a multiple selection, a press on an option that is not selected, dragged with a mouse or a pen, selects the range it passes over. The drag goes on when the pointer leaves the list, and the list scrolls while the pointer is held past its edge. A drag that begins on a selected option selects nothing: that drag belongs to the selection, to carry it elsewhere.

In a popup. A list opened from a button highlights the option under the pointer, chooses on release โ€” so that a press which opened the popup can be carried to an option โ€” and opens each time on what is chosen.

Touch. Each option is at least 44 CSS pixels tall. A drag scrolls. In a multiple selection a tap adds or removes an option, since touch provides no modifier keys, and a long press adds the option under the finger; while the finger stays down, moving it selects the range it passes over and the list does not scroll. In a single selection a long press is a secondary press: it selects the option and asks for its contextual menu. The platform's own callout and text selection do not appear over an option.

Acting by touch. There is no double tap. Where options have an action, a tap performs it wherever selecting would change nothing: on the selected option of a single selection; and, in a multiple selection that replaces, while nothing is selected โ€” there a long press begins the selection, taps then add to and remove from it, and when it is empty again a tap acts once more. A multiple selection that toggles has no action by touch: give each option its own control for it.

Pen. A pen selects nothing as it presses, since its press may become a scroll; its tap adds or removes like a finger's, and its drag selects a range like a mouse's, adding to what was selected. Where the platform scrolls with the pen instead, the selection is left as it was.

Right-to-left. In a horizontal listbox, the arrow keys follow the reading direction.

Assistive technology. An option answers being activated by assistive technology, which presses nothing. Expose the list as a listbox with its name, each option with its selected and disabled state, and each group with its heading as its name. A listbox that allows several selections says so.

Behaviour

Semantics

Is a listbox of options, named by its label
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await expect.element(screen.getByRole("listbox", { name: "Fruits" })).toBeVisible();
expect(screen.getByRole("option").elements()).toHaveLength(FRUITS.length);
States whether more than one option can be selected
const screen = await render(
  <>
    <Listbox aria-label="One">{fruits()}</Listbox>
    <Listbox aria-label="Many" selectionMode="multiple">
      {fruits()}
    </Listbox>
  </>,
);
await expect
  .element(screen.getByRole("listbox", { name: "One" }))
  .not.toHaveAttribute("aria-multiselectable");
await expect
  .element(screen.getByRole("listbox", { name: "Many" }))
  .toHaveAttribute("aria-multiselectable", "true");
Every option states whether it is selected
const screen = await render(
  <Listbox aria-label="Fruits" defaultValue="Banana">
    {fruits()}
  </Listbox>,
);
for (const name of FRUITS) {
  await expect
    .element(option(screen, name))
    .toHaveAttribute("aria-selected", String(name === "Banana"));
}
A disabled option says so
const screen = await render(<Listbox aria-label="Fruits">{fruits(["Cherry"])}</Listbox>);
await expect.element(option(screen, "Cherry")).toHaveAttribute("aria-disabled", "true");
A group is exposed as a group, named by its label
const screen = await render(
  <Listbox aria-label="Produce">
    <ListboxGroup label="Fruit">
      <ListboxItem value="apple">Apple</ListboxItem>
    </ListboxGroup>
    <ListboxGroup label="Vegetables">
      <ListboxItem value="leek">Leek</ListboxItem>
    </ListboxGroup>
  </Listbox>,
);
await expect.element(screen.getByRole("group", { name: "Vegetables" })).toBeVisible();
await expectAccessible(screen.container);
On a touchscreen, every option is at least 44 pixels tallChromium ยท touch, WebKit ยท touch

Not yet written.

A list with no options says what it was given to say of that, as its description
const screen = await render(
  <>
    <Listbox aria-label="Results" emptyState="No results" />
    <Listbox aria-label="Fruits" emptyState="No fruits">
      {fruits()}
    </Listbox>
  </>,
);
await expect.element(screen.getByText("No results")).toBeVisible();
await expect
  .element(screen.getByRole("listbox", { name: "Results" }))
  .toHaveAccessibleDescription("No results");
await expect.element(screen.getByText("No fruits")).not.toBeInTheDocument();
await expect.element(screen.getByRole("listbox", { name: "Fruits" })).toHaveAccessibleDescription("");
await expectAccessible(screen.container);
Has no accessibility violations, in either selection mode
const screen = await render(
  <>
    <Listbox aria-label="One" defaultValue="Apple">
      {fruits(["Date"])}
    </Listbox>
    <Listbox aria-label="Many" selectionMode="multiple" defaultValue={["Apple", "Cherry"]}>
      {fruits(["Date"])}
    </Listbox>
  </>,
);
await expectAccessible(screen.container);

Focus

Is a single stop in the tab sequence
const screen = await render(
  <>
    <input aria-label="Before" />
    <Listbox aria-label="Fruits">{fruits()}</Listbox>
    <input aria-label="After" />
  </>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.element(option(screen, "Apple")).toHaveFocus();
await userEvent.tab();
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();
Tab enters on the first selected option
const screen = await render(
  <>
    <input aria-label="Before" />
    <Listbox aria-label="Fruits" selectionMode="multiple" defaultValue={["Cherry", "Banana"]}>
      {fruits()}
    </Listbox>
  </>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.element(option(screen, "Banana")).toHaveFocus();
Tab enters on the first enabled option when nothing is selected
const screen = await render(
  <>
    <input aria-label="Before" />
    <Listbox aria-label="Fruits">{fruits(["Apple"])}</Listbox>
  </>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.element(option(screen, "Apricot")).toHaveFocus();
Returning to the list returns to the option last focused
const screen = await render(
  <>
    <input aria-label="Before" />
    <Listbox aria-label="Fruits" selectionBehavior="toggle">
      {fruits()}
    </Listbox>
  </>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await press("{ArrowDown}{ArrowDown}");
await userEvent.tab({ shift: true });
await userEvent.tab();
await expect.element(option(screen, "Banana")).toHaveFocus();
When the option holding the tab stop goes away, another takes it
function Removable() {
  const [items, setItems] = useState(["Apple", "Banana", "Cherry"]);
  return (
    <>
      <input aria-label="Before" />
      <Listbox aria-label="Fruits" selectionMode="multiple" selectionBehavior="toggle">
        {items.map((name) => (
          <ListboxItem key={name} value={name}>
            {name}
          </ListboxItem>
        ))}
      </Listbox>
      <button type="button" onClick={() => setItems((all) => all.filter((n) => n !== "Banana"))}>
        Remove Banana
      </button>
    </>
  );
}
const screen = await render(<Removable />);
await userEvent.click(option(screen, "Banana"));
await userEvent.click(screen.getByRole("button", { name: "Remove Banana" }));
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.element(option(screen, "Apple")).toHaveFocus();
When the option that has focus goes away, focus stays in the list
function Vanishing() {
  const [items, setItems] = useState(["Apple", "Banana", "Cherry"]);
  return (
    <Listbox
      aria-label="Fruits"
      onAction={(name) => setItems((all) => all.filter((other) => other !== name))}
    >
      {items.map((name) => (
        <ListboxItem key={name} value={name}>
          {name}
        </ListboxItem>
      ))}
    </Listbox>
  );
}
const screen = await render(<Vanishing />);
await userEvent.click(option(screen, "Banana"));
await press("{Enter}");
await expect.element(option(screen, "Banana")).not.toBeInTheDocument();
await expect.element(option(screen, "Apple")).toHaveFocus();
await press("{ArrowDown}");
await expect.element(option(screen, "Cherry")).toHaveFocus();
An empty list is still reachable by Tab
const screen = await render(
  <>
    <input aria-label="Before" />
    <Listbox aria-label="Nothing" />
  </>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.element(screen.getByRole("listbox")).toHaveFocus();

Keyboard navigation

ArrowDown and ArrowUp move to the next and previous option
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Apple"));
await press("{ArrowDown}");
await expect.element(option(screen, "Apricot")).toHaveFocus();
await press("{ArrowDown}{ArrowUp}{ArrowUp}");
await expect.element(option(screen, "Apple")).toHaveFocus();
Stops at either end rather than wrapping
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Apple"));
await press("{ArrowUp}");
await expect.element(option(screen, "Apple")).toHaveFocus();
await press("{End}{ArrowDown}");
await expect.element(option(screen, "Date")).toHaveFocus();
Home and End move to the first and last option
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Banana"));
await press("{End}");
await expect.element(option(screen, "Date")).toHaveFocus();
await press("{Home}");
await expect.element(option(screen, "Apple")).toHaveFocus();
Disabled options are passed over
const screen = await render(<Listbox aria-label="Fruits">{fruits(["Apricot", "Date"])}</Listbox>);
await userEvent.click(option(screen, "Apple"));
await press("{ArrowDown}");
await expect.element(option(screen, "Banana")).toHaveFocus();
await press("{End}");
await expect.element(option(screen, "Cherry")).toHaveFocus();
Options in groups are one sequence
const screen = await render(
  <Listbox aria-label="Produce">
    <ListboxGroup label="Fruit">
      <ListboxItem value="apple">Apple</ListboxItem>
    </ListboxGroup>
    <ListboxGroup label="Vegetables">
      <ListboxItem value="leek">Leek</ListboxItem>
    </ListboxGroup>
  </Listbox>,
);
await userEvent.click(option(screen, "Apple"));
await press("{ArrowDown}");
await expect.element(option(screen, "Leek")).toHaveFocus();
A horizontal list moves with ArrowLeft and ArrowRight, mirrored right-to-left
const screen = await render(
  <>
    <Listbox aria-label="LTR" orientation="horizontal">
      <ListboxItem value="a">A</ListboxItem>
      <ListboxItem value="b">B</ListboxItem>
    </Listbox>
    <Listbox aria-label="RTL" orientation="horizontal" dir="rtl">
      <ListboxItem value="c">C</ListboxItem>
      <ListboxItem value="d">D</ListboxItem>
    </Listbox>
  </>,
);
await expect
  .element(screen.getByRole("listbox", { name: "LTR" }))
  .toHaveAttribute("aria-orientation", "horizontal");
await userEvent.click(option(screen, "A"));
await press("{ArrowRight}");
await expect.element(option(screen, "B")).toHaveFocus();
await userEvent.click(option(screen, "C"));
await press("{ArrowLeft}");
await expect.element(option(screen, "D")).toHaveFocus();
In a list where focus follows the pointer, the option under it is focused
const screen = await render(
  <Listbox aria-label="Fruits" focusFollowsPointer selectionBehavior="toggle">
    {fruits(["Cherry"])}
  </Listbox>,
);
await userEvent.click(option(screen, "Apple"));
await userEvent.hover(option(screen, "Banana"));
await expect.element(option(screen, "Banana")).toHaveFocus();
await userEvent.hover(option(screen, "Cherry"));
await expect.element(option(screen, "Banana")).toHaveFocus();
expect(selectedNames(screen)).toEqual(["Apple"]);
In such a list, releasing the pointer on an option chooses it, wherever the press began
const onAction = vi.fn();
await render(
  <>
    <button type="button">Elsewhere</button>
    <Listbox aria-label="Fruits" focusFollowsPointer onAction={onAction}>
      {fruits()}
    </Listbox>
  </>,
);
await commands.pressDragRelease("button", '[role="option"]:nth-child(3)');
expect(onAction).toHaveBeenCalledWith("Banana");
PageUp and PageDown move by a page of options
const screen = await render(
  // Tall enough to show four options and part of a fifth.
  <Listbox aria-label="Fruits" style={{ height: hasTouch ? 198 : 150 }}>
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Apple"));
await press("{PageDown}");
await expect.element(option(screen, "Blueberry")).toHaveFocus();
expect(selectedNames(screen)).toEqual(["Blueberry"]);
await press("{PageDown}");
await expect.element(option(screen, "Date")).toHaveFocus();
await press("{PageUp}");
await expect.element(option(screen, "Banana")).toHaveFocus();
In a horizontal list, PageUp and PageDown move by a page along it
const screen = await render(
  // Wide enough to show three options and part of a fourth.
  <Listbox aria-label="Numbers" orientation="horizontal" style={{ width: 200 }}>
    {["N1", "N2", "N3", "N4", "N5", "N6", "N7"].map((name) => (
      <ListboxItem key={name} value={name} style={{ width: 60, flexShrink: 0 }}>
        {name}
      </ListboxItem>
    ))}
  </Listbox>,
);
await userEvent.click(option(screen, "N1"));
await press("{PageDown}");
await expect.element(option(screen, "N3")).toHaveFocus();
await press("{PageDown}");
await expect.element(option(screen, "N5")).toHaveFocus();
await press("{PageUp}");
await expect.element(option(screen, "N3")).toHaveFocus();
Options that share a value do not trap the arrow keys
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const screen = await render(
  <Listbox aria-label="Letters" selectionBehavior="toggle">
    <ListboxItem value="a">First</ListboxItem>
    <ListboxItem value="a">Second</ListboxItem>
    <ListboxItem value="b">Third</ListboxItem>
  </Listbox>,
);
await userEvent.click(option(screen, "First"));
await press("{ArrowDown}");
await expect.element(option(screen, "Second")).toHaveFocus();
await press("{ArrowDown}");
await expect.element(option(screen, "Third")).toHaveFocus();
await press("{ArrowUp}");
await expect.element(option(screen, "Second")).toHaveFocus();
warn.mockRestore();

Typeahead

Typing a character moves to the next option that starts with it
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Apple"));
await press("c");
await expect.element(option(screen, "Cherry")).toHaveFocus();
Typing quickly matches the whole prefix
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Date"));
await press("blu");
await expect.element(option(screen, "Blueberry")).toHaveFocus();
Repeating a character cycles through the options that start with it
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Date"));
await press("a");
await expect.element(option(screen, "Apple")).toHaveFocus();
await press("a");
await expect.element(option(screen, "Apricot")).toHaveFocus();
await press("a");
await expect.element(option(screen, "Apple")).toHaveFocus();
Matching ignores case and accents
const screen = await render(
  <Listbox aria-label="Cities">
    <ListboxItem value="a">Amsterdam</ListboxItem>
    <ListboxItem value="z">Zรผrich</ListboxItem>
  </Listbox>,
);
await userEvent.click(option(screen, "Amsterdam"));
await press("zu");
await expect.element(option(screen, "Zรผrich")).toHaveFocus();
A key that moves focus ends the query, so the next character starts afresh
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Date"));
await press("b{Home}c");
await expect.element(option(screen, "Cherry")).toHaveFocus();
A space continues a query that has started
const screen = await render(
  <Listbox aria-label="Cities">
    <ListboxItem value="new-delhi">New Delhi</ListboxItem>
    <ListboxItem value="new-york">New York</ListboxItem>
  </Listbox>,
);
await userEvent.click(option(screen, "New Delhi"));
await press("new y");
await expect.element(option(screen, "New York")).toHaveFocus();
An option's typeahead text may differ from its visible label
const screen = await render(
  <Listbox aria-label="Files">
    <ListboxItem value="a" textValue="Notes">
      ๐Ÿ“„ Notes
    </ListboxItem>
    <ListboxItem value="b" textValue="Photos">
      ๐Ÿ–ผ Photos
    </ListboxItem>
  </Listbox>,
);
await userEvent.click(option(screen, "๐Ÿ“„ Notes"));
await press("p");
await expect.element(option(screen, "๐Ÿ–ผ Photos")).toHaveFocus();
Leaving the list ends the query: what is typed on return starts afresh
const screen = await render(
  <>
    <input aria-label="Before" />
    <Listbox aria-label="Fruits">{fruits()}</Listbox>
  </>,
);
await userEvent.click(option(screen, "Date"));
await press("b");
await expect.element(option(screen, "Banana")).toHaveFocus();
// Left with the pointer: a key that took focus away would itself have ended the query.
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await press("a");
// Still "ba", it would have stayed on Banana.
await expect.element(option(screen, "Apple")).toHaveFocus();

Single selection

A click selects the option
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Banana"));
expect(selectedNames(screen)).toEqual(["Banana"]);
A tap selects the optionChromium ยท touch, WebKit ยท touch

Not yet written.

Selection follows focus
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Apple"));
await press("{ArrowDown}");
expect(selectedNames(screen)).toEqual(["Apricot"]);
await press("c");
expect(selectedNames(screen)).toEqual(["Cherry"]);
With the command key held, arrows move focus and leave the selection
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
await userEvent.click(option(screen, "Apple"));
await press(`{${COMMAND}>}{ArrowDown}{ArrowDown}{/${COMMAND}}`);
await expect.element(option(screen, "Banana")).toHaveFocus();
expect(selectedNames(screen)).toEqual(["Apple"]);
await press(" ");
expect(selectedNames(screen)).toEqual(["Banana"]);
Toggle behaviour: arrows move focus only, and Space selects
const screen = await render(
  <Listbox aria-label="Fruits" selectionBehavior="toggle">
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Apple"));
await press("{ArrowDown}");
expect(selectedNames(screen)).toEqual(["Apple"]);
await press(" ");
expect(selectedNames(screen)).toEqual(["Apricot"]);
The command key with A selects nothing: not the options, and not the text of the page
const screen = await render(
  <>
    <p>Text of the page</p>
    <Listbox aria-label="Fruits" defaultValue="Banana">
      {fruits()}
    </Listbox>
  </>,
);
await userEvent.click(option(screen, "Banana"));
await press(`{${COMMAND}>}a{/${COMMAND}}`);
expect(getSelection()?.toString()).toBe("");
expect(selectedNames(screen)).toEqual(["Banana"]);
A click that no press came before, as assistive technology makes, selects the option
const screen = await render(<Listbox aria-label="Fruits">{fruits()}</Listbox>);
(option(screen, "Cherry").element() as HTMLElement).click();
await expect.poll(() => selectedNames(screen)).toEqual(["Cherry"]);
A secondary press selects the option it lands onChromium, Firefox, WebKit

Not yet written.

A long press on an option asks for its context menu, and selects itChromium ยท touch

Not yet written.

Command-click on the selected option clears the selection
const screen = await render(
  <Listbox aria-label="Fruits" defaultValue="Banana">
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Banana"), { modifiers: [COMMAND] });
expect(selectedNames(screen)).toEqual([]);
When a selection is required, the selected option cannot be deselected
const screen = await render(
  <Listbox aria-label="Fruits" defaultValue="Banana" disallowEmptySelection>
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Banana"), { modifiers: [COMMAND] });
expect(selectedNames(screen)).toEqual(["Banana"]);

Multiple selection

A click selects only that option
const screen = await render(many({ defaultValue: ["Apple", "Banana"] }));
await userEvent.click(option(screen, "Cherry"));
expect(selectedNames(screen)).toEqual(["Cherry"]);
Command-click adds or removes one option and keeps the rest
const screen = await render(many());
await userEvent.click(option(screen, "Apple"));
await userEvent.click(option(screen, "Banana"), { modifiers: [COMMAND] });
expect(selectedNames(screen)).toEqual(["Apple", "Banana"]);
await userEvent.click(option(screen, "Apple"), { modifiers: [COMMAND] });
expect(selectedNames(screen)).toEqual(["Banana"]);
Shift-click selects the range from the last clicked option
const screen = await render(many());
await userEvent.click(option(screen, "Apricot"));
await userEvent.click(option(screen, "Blueberry"), { modifiers: ["Shift"] });
expect(selectedNames(screen)).toEqual(["Apricot", "Banana", "Blueberry"]);
A second shift-click redraws the range from the same anchor
const screen = await render(many());
await userEvent.click(option(screen, "Banana"));
await userEvent.click(option(screen, "Cherry"), { modifiers: ["Shift"] });
await userEvent.click(option(screen, "Apple"), { modifiers: ["Shift"] });
expect(selectedNames(screen)).toEqual(["Apple", "Apricot", "Banana"]);
Shift-click after command-click adds a range to the selection
const screen = await render(many());
await userEvent.click(option(screen, "Apple"));
await userEvent.click(option(screen, "Banana"), { modifiers: [COMMAND] });
await userEvent.click(option(screen, "Cherry"), { modifiers: ["Shift"] });
expect(selectedNames(screen)).toEqual(["Apple", "Banana", "Blueberry", "Cherry"]);
Arrows select as they move; with Shift they extend
const screen = await render(many());
await userEvent.click(option(screen, "Apple"));
await press("{ArrowDown}");
expect(selectedNames(screen)).toEqual(["Apricot"]);
await press("{Shift>}{ArrowDown}{ArrowDown}{/Shift}");
expect(selectedNames(screen)).toEqual(["Apricot", "Banana", "Blueberry"]);
await press("{Shift>}{Home}{/Shift}");
expect(selectedNames(screen)).toEqual(["Apple", "Apricot"]);
With the command key held, arrows move focus and Space adds the option
const screen = await render(many());
await userEvent.click(option(screen, "Apple"));
await press(`{${COMMAND}>}{ArrowDown}{ArrowDown} {/${COMMAND}}`);
expect(selectedNames(screen)).toEqual(["Apple", "Banana"]);
Command-A selects every enabled option
const screen = await render(many());
await userEvent.click(option(screen, "Apple"));
await press(`{${COMMAND}>}a{/${COMMAND}}`);
expect(selectedNames(screen)).toEqual(["Apple", "Apricot", "Banana", "Blueberry", "Cherry"]);
Shift with an arrow extends a selection that was given, from the focused option
const screen = await render(many({ defaultValue: ["Apricot"] }));
(option(screen, "Apricot").element() as HTMLElement).focus();
await press("{Shift>}{ArrowDown}{/Shift}");
expect(selectedNames(screen)).toEqual(["Apricot", "Banana"]);
A press on one of several selected options keeps them all, until it ends as a click on that oneChromium, Firefox, WebKit

Not yet written.

A secondary press within the selection keeps it, and outside it selects only what it lands onChromium, Firefox, WebKit

Not yet written.

An option that is selected but unavailable stays selected as others change
const onValueChange = vi.fn();
const screen = await render(
  <Listbox
    aria-label="Fruits"
    selectionMode="multiple"
    selectionBehavior="toggle"
    defaultValue={["Apple", "Date"]}
    onValueChange={onValueChange}
  >
    {fruits(["Date"])}
  </Listbox>,
);
await userEvent.click(option(screen, "Banana"));
expect(onValueChange).toHaveBeenLastCalledWith(["Apple", "Banana", "Date"]);
await press(`{${COMMAND}>}a{/${COMMAND}}`);
expect(onValueChange).toHaveBeenLastCalledWith([...FRUITS]);
Toggle behaviour: every click adds or removes
const screen = await render(many({ behavior: "toggle" }));
await userEvent.click(option(screen, "Apple"));
await userEvent.click(option(screen, "Cherry"));
expect(selectedNames(screen)).toEqual(["Apple", "Cherry"]);
await userEvent.click(option(screen, "Apple"));
expect(selectedNames(screen)).toEqual(["Cherry"]);
A range never selects a disabled option
const screen = await render(
  <Listbox aria-label="Fruits" selectionMode="multiple">
    {fruits(["Banana"])}
  </Listbox>,
);
await userEvent.click(option(screen, "Apple"));
await userEvent.click(option(screen, "Cherry"), { modifiers: ["Shift"] });
expect(selectedNames(screen)).toEqual(["Apple", "Apricot", "Blueberry", "Cherry"]);
A tap adds or removes an option, since touch has no modifier keysChromium ยท touch, WebKit ยท touch

Not yet written.

Shift with Home or End extends the selection to that end, with the command key or without
const screen = await render(many());
await userEvent.click(option(screen, "Banana"));
await press("{Shift>}{End}{/Shift}");
expect(selectedNames(screen)).toEqual(["Banana", "Blueberry", "Cherry"]);
await press(`{Shift>}{${COMMAND}>}{Home}{/${COMMAND}}{/Shift}`);
expect(selectedNames(screen)).toEqual(["Apple", "Apricot", "Banana"]);
Dragging across options selects the range it passes overChromium, Firefox, WebKit

Not yet written.

The range follows the pointer back as it returns, and on past where it beganChromium, Firefox, WebKit

Not yet written.

With the command key held, a drag adds its range to what was selectedChromium, Firefox, WebKit

Not yet written.

A drag from a selected option draws no range: the selection is that drag's to carryChromium, Firefox, WebKit

Not yet written.

A drag goes on outside the list, following the pointer along itChromium, Firefox, WebKit

Not yet written.

A drag held past the list's edge scrolls it, selecting as it goes, until it is releasedChromium, Firefox, WebKit

Not yet written.

In a horizontal list a drag selects along it, in either directionChromium, Firefox, WebKit

Not yet written.

A pen selects nothing as it presses; its drag adds the range it passes over, and its tap adds or removesChromium

Not yet written.

A gesture the platform takes back leaves the selection as it was before the pressChromium ยท touch

Not yet written.

A long press adds the option and keeps the rest; lifting the finger does not undo itChromium ยท touch

Not yet written.

After a long press, the finger selects the range it passes over, and the list does not scroll under itChromium ยท touch

Not yet written.

A finger that moves before its press is long is scrolling, and selects nothingChromium ยท touch

Not yet written.

Actions

Enter performs the focused option's action
const onAction = vi.fn();
const screen = await render(
  <Listbox aria-label="Fruits" onAction={onAction}>
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Apple"));
await press("{ArrowDown}{Enter}");
expect(onAction).toHaveBeenCalledWith("Apricot");
A key held down performs the action once
const onAction = vi.fn();
const screen = await render(
  <Listbox aria-label="Fruits" onAction={onAction}>
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Apple"));
await press("{Enter}");
option(screen, "Apple")
  .element()
  .dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", repeat: true, bubbles: true }));
expect(onAction).toHaveBeenCalledTimes(1);
A double click performs the option's action
const onAction = vi.fn();
const screen = await render(
  <Listbox aria-label="Fruits" onAction={onAction}>
    {fruits()}
  </Listbox>,
);
await userEvent.dblClick(option(screen, "Banana"));
expect(onAction).toHaveBeenCalledWith("Banana");
By touch, a tap selects an option and a tap on the selected option performs its actionChromium ยท touch, WebKit ยท touch

Not yet written.

Two quick taps select an option and perform its action, onceChromium ยท touch, WebKit ยท touch

Not yet written.

By touch in a multiple selection, a tap performs the action while nothing is selected, and adds or removes once something isChromium ยท touch, WebKit ยท touch

Not yet written.

Ten thousand options

An arrow key re-renders only the two options it moves between, each once
const renders = new Map<string, number>();
await render(
  <Listbox aria-label="Options" style={{ height: 300 }}>
    {counted(renders)}
  </Listbox>,
);
expect(renders.size).toBe(MANY.length);
await commands.clickSelector(byKey("Option 1"));
renders.clear();
await press("{ArrowDown}");
await expect.poll(focusedKey).toBe("Option 2");
expect(document.querySelector(byKey("Option 2"))?.getAttribute("aria-selected")).toBe("true");
// Once for both focus and selection: a second pass over so many options is felt.
expect(Object.fromEntries(renders)).toEqual({ "Option 1": 1, "Option 2": 1 });
A click that adds an option re-renders that option and the one focus left
const renders = new Map<string, number>();
await render(
  <Listbox aria-label="Options" selectionMode="multiple" style={{ height: 300 }}>
    {counted(renders)}
  </Listbox>,
);
await commands.clickSelector(byKey("Option 2"));
renders.clear();
await commands.clickSelector(byKey("Option 4"), { modifiers: [COMMAND] });
expect(document.querySelectorAll('[aria-selected="true"]')).toHaveLength(2);
expect([...renders.keys()].toSorted()).toEqual(["Option 2", "Option 4"]);
An arrow key reads the options once, and the text of none of them
const screen = await render(
  <Listbox aria-label="Options" style={{ height: 300 }}>
    {MANY.map((name) => (
      <ListboxItem key={name} value={name}>
        {name}
      </ListboxItem>
    ))}
  </Listbox>,
);
await commands.clickSelector(byKey("Option 1"));
const readings = vi.spyOn(screen.getByRole("listbox").element(), "querySelectorAll");
const texts = vi.spyOn(Node.prototype, "textContent", "get");
await press("{ArrowDown}");
await expect.poll(focusedKey).toBe("Option 2");
const counts = { readings: readings.mock.calls.length, texts: texts.mock.calls.length };
texts.mockRestore();
readings.mockRestore();
expect(counts).toEqual({ readings: 1, texts: 0 });
Typing reads the text of options only as far as the one it finds
const names = [...MANY.slice(0, 5), "Quince", ...MANY.slice(5)];
await render(
  <Listbox aria-label="Options" style={{ height: 300 }}>
    {names.map((name) => (
      <ListboxItem key={name} value={name}>
        {name}
      </ListboxItem>
    ))}
  </Listbox>,
);
await commands.clickSelector(byKey("Option 1"));
const texts = vi.spyOn(Node.prototype, "textContent", "get");
await press("q");
await expect.poll(focusedKey).toBe("Quince");
const count = texts.mock.calls.length;
texts.mockRestore();
// Options 2 to 5, and Quince.
expect(count).toBe(5);

API

onValueChange receives a single selection's new value, once per change
const onValueChange = vi.fn();
const screen = await render(
  <Listbox aria-label="Fruits" onValueChange={onValueChange}>
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Cherry"));
await userEvent.click(option(screen, "Cherry"));
expect(onValueChange.mock.calls).toEqual([["Cherry"]]);
onValueChange receives a multiple selection in the list's order
const onValueChange = vi.fn();
const screen = await render(
  <Listbox aria-label="Fruits" selectionMode="multiple" onValueChange={onValueChange}>
    {fruits()}
  </Listbox>,
);
await userEvent.click(option(screen, "Cherry"));
await userEvent.click(option(screen, "Apple"), { modifiers: [COMMAND] });
expect(onValueChange).toHaveBeenLastCalledWith(["Apple", "Cherry"]);
A controlled value changes only when its owner changes it
function Picker() {
  const [value, setValue] = useState<string | null>("Apple");
  return (
    <Listbox
      aria-label="Fruits"
      value={value}
      onValueChange={(next) => setValue(next === "Cherry" ? value : next)}
    >
      {fruits()}
    </Listbox>
  );
}
const screen = await render(<Picker />);
await userEvent.click(option(screen, "Banana"));
expect(selectedNames(screen)).toEqual(["Banana"]);
await userEvent.click(option(screen, "Cherry"));
expect(selectedNames(screen)).toEqual(["Banana"]);
When selectionMode changes, the value is kept in the new mode's form and onValueChange hears it
const onValueChange = vi.fn();
function Modes() {
  const [mode, setMode] = useState<"multiple" | "single" | "multiple again">("multiple");
  return (
    <>
      {mode === "single" ? (
        <Listbox key="list" aria-label="Fruits" onValueChange={onValueChange}>
          {fruits()}
        </Listbox>
      ) : (
        <Listbox
          key="list"
          aria-label="Fruits"
          selectionMode="multiple"
          defaultValue={["Banana", "Cherry"]}
          onValueChange={onValueChange}
        >
          {fruits()}
        </Listbox>
      )}
      <button type="button" onClick={() => setMode(mode === "multiple" ? "single" : "multiple again")}>
        Change mode
      </button>
    </>
  );
}
const screen = await render(<Modes />);
await userEvent.click(screen.getByRole("button", { name: "Change mode" }));
// The first of those selected is the one that stays.
await expect.poll(() => selectedNames(screen)).toEqual(["Banana"]);
expect(onValueChange).toHaveBeenLastCalledWith("Banana");
await userEvent.click(screen.getByRole("button", { name: "Change mode" }));
await expect.poll(() => onValueChange.mock.lastCall).toEqual([["Banana"]]);
expect(selectedNames(screen)).toEqual(["Banana"]);
While only one may be selected, a controlled value of several is read as its first
function Modes() {
  const [multiple, setMultiple] = useState(true);
  const value = ["Banana", "Cherry"];
  return (
    <>
      {multiple ? (
        <Listbox key="list" aria-label="Fruits" selectionMode="multiple" value={value}>
          {fruits()}
        </Listbox>
      ) : (
        // An owner that has not yet answered the change of mode.
        <Listbox key="list" aria-label="Fruits" value={value as unknown as string}>
          {fruits()}
        </Listbox>
      )}
      <button type="button" onClick={() => setMultiple(false)}>
        Allow one
      </button>
    </>
  );
}
const screen = await render(<Modes />);
expect(selectedNames(screen)).toEqual(["Banana", "Cherry"]);
await userEvent.click(screen.getByRole("button", { name: "Allow one" }));
await expect.element(screen.getByRole("listbox")).not.toHaveAttribute("aria-multiselectable");
expect(selectedNames(screen)).toEqual(["Banana"]);
Options that share a value are reported in development, once
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
function Twins() {
  const [more, setMore] = useState(false);
  return (
    <>
      <Listbox aria-label="Letters">
        <ListboxItem value="a">First</ListboxItem>
        <ListboxItem value="a">Second</ListboxItem>
        {more && <ListboxItem value="b">Third</ListboxItem>}
      </Listbox>
      <button type="button" onClick={() => setMore(true)}>
        More
      </button>
    </>
  );
}
const screen = await render(<Twins />);
await userEvent.click(screen.getByRole("button", { name: "More" }));
await expect.element(option(screen, "Third")).toBeVisible();
const reports = warn.mock.calls.map(([message]) => String(message));
warn.mockRestore();
expect(reports).toHaveLength(1);
expect(reports[0]).toContain('"a"');

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/listbox.json
Source

src/components/listbox/listbox.tsx

"use client";

import {
  createContext,
  use,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useReducer,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";
import type {
  ComponentProps,
  KeyboardEvent,
  MouseEvent,
  PointerEvent,
  ReactElement,
  ReactNode,
  RefObject,
} from "react";
import { flushSync } from "react-dom";
import { cn } from "../../lib/utils.ts";
import { composeEventHandlers } from "../../lib/compose-event-handlers.ts";
import { focusRingClassName } from "../../lib/focus-class.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { isCommandKey } from "../../lib/platform.ts";
import {
  closestItemElement,
  collectionItemProps,
  duplicateKeys,
  focusItem,
  getCollection,
  getCollectionItem,
} from "../../primitives/collection.ts";
import type { CollectionItem } from "../../primitives/collection.ts";
import { moveForKey, pageStep, step } from "../../primitives/navigation.ts";
import type { Extent, Orientation } from "../../primitives/navigation.ts";
import {
  applyIntent,
  autoScrollSpeed,
  inOrder,
  itemAt,
  pointerIntent,
  sameSelection,
  selectAll,
  tapActs,
} from "../../primitives/selection.ts";
import type { SelectionBehavior, SelectionIntent, SelectionState } from "../../primitives/selection.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
import { matchTypeahead, useTypeahead } from "../../primitives/typeahead.ts";

interface SingleSelection {
  selectionMode?: "single";
  value?: string | null;
  defaultValue?: string | null;
  onValueChange?: (value: string | null) => void;
}

interface MultipleSelection {
  selectionMode: "multiple";
  value?: readonly string[];
  defaultValue?: readonly string[];
  onValueChange?: (value: string[]) => void;
}

export type ListboxProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> &
  (SingleSelection | MultipleSelection) & {
    selectionBehavior?: SelectionBehavior;
    /** Keep at least one item selected once something is. */
    disallowEmptySelection?: boolean;
    orientation?: Orientation;
    /**
     * An item's primary action: Enter, or a double click. By touch, a tap where
     * selecting would change nothing; see `tapActs` in primitives/selection.ts.
     */
    onAction?: (value: string) => void;
    /**
     * Behave as a list in a popup does: the option under the pointer is
     * highlighted, releasing on an option chooses it โ€” so a press that opened
     * the popup can be carried to an option โ€” and the chosen option is marked
     * rather than filled, leaving the fill to the highlight.
     */
    focusFollowsPointer?: boolean;
    /**
     * Shown in place of the options when there are none, and read out as the
     * list's description. Words, not controls: it is not itself reachable.
     */
    emptyState?: ReactNode;
  };

type Value = string | null | readonly string[];

/** What a press may still become: a long press, or a drag across options. */
interface Press {
  readonly pointerId: number;
  readonly pointerType: string;
  readonly key: string;
  readonly element: HTMLElement;
  readonly x: number;
  readonly y: number;
  /**
   * Whether a drag from here draws a range. From an option that was already
   * selected it does not: that drag is the selection's own, to be carried.
   */
  readonly drawsRange: boolean;
  /** The selection before the press, which a gesture the platform takes back returns to. */
  readonly before: { readonly value: Value; readonly range: Pick<SelectionState, "anchor" | "base"> };
  timer: ReturnType<typeof setTimeout> | undefined;
  longPressed: boolean;
  drag: Drag | null;
}

interface Drag {
  x: number;
  y: number;
  /** How far past the list's edge the pointer is, signed; 0 inside. */
  out: number;
  /** The options as last read, and the count of changes then: one reading serves until they change. */
  reading: Reading | null;
  readAt: number;
  reached: string | null;
  frame: number;
  time: number;
  /** Scrolling owed but less than a pixel: some engines scroll by whole pixels only. */
  carry: number;
  readonly rtl: boolean;
}

interface Reading {
  readonly all: readonly CollectionItem[];
  readonly enabled: readonly CollectionItem[];
}

/** Pixels a pointer travels before its press is a drag. */
const DRAG_THRESHOLD = 4;
/** Pixels a finger may wander and still be holding still. */
const TOUCH_SLOP = 10;
/** As long as the platforms take to call a press long. */
const LONG_PRESS = 500;

const SELECTED = 1;
const TAB_STOP = 2;
const NO_TAB_STOP = 1;
const EMPTY = 2;

/**
 * What each option draws from โ€” whether it is selected, whether it is the tab
 * stop โ€” held outside React's tree, with each option listening for its own key.
 * Through context, every focus move re-rendered every option; this way a change
 * reaches the options it changes and no others, and the attributes stay
 * React's to write.
 */
function createListStore(initial: ReadonlySet<string>, onItemsChange: () => void) {
  const listeners = new Map<string, Set<() => void>>();
  const rootListeners = new Set<() => void>();
  const notify = (key: string | null): void => {
    if (key !== null) listeners.get(key)?.forEach((listener) => listener());
  };
  const notifyRoot = (): void => rootListeners.forEach((listener) => listener());

  const store = {
    selected: initial,
    tabStop: null as string | null,
    /** The option focus was last on. */
    focused: null as string | null,
    empty: false,
    /** Options have come, gone or changed since the collection was last read. */
    stale: true,
    /** Focus was on an option that has gone: it is owed to the one that takes its place. */
    focusOwed: false,
    /** The shared values last warned of, so that each is said once. */
    reported: "",
    /** Counts the times options have changed: a reading is good while this stands still. */
    changes: 0,

    subscribe(key: string, listener: () => void): () => void {
      let set = listeners.get(key);
      if (set === undefined) listeners.set(key, (set = new Set()));
      set.add(listener);
      return () => {
        set.delete(listener);
        if (set.size === 0 && listeners.get(key) === set) listeners.delete(key);
      };
    },
    subscribeRoot(listener: () => void): () => void {
      rootListeners.add(listener);
      return () => rootListeners.delete(listener);
    },
    itemState: (key: string): number =>
      (store.selected.has(key) ? SELECTED : 0) | (store.tabStop === key ? TAB_STOP : 0),
    rootState: (): number => (store.tabStop === null ? NO_TAB_STOP : 0) | (store.empty ? EMPTY : 0),

    setSelected(next: ReadonlySet<string>): void {
      const previous = store.selected;
      if (previous === next) return;
      store.selected = next;
      for (const key of previous) if (!next.has(key)) notify(key);
      for (const key of next) if (!previous.has(key)) notify(key);
    },
    setTabStop(next: string | null): void {
      const previous = store.tabStop;
      if (previous === next) return;
      store.tabStop = next;
      notify(previous);
      notify(next);
      if ((previous === null) !== (next === null)) notifyRoot();
    },
    setEmpty(next: boolean): void {
      if (store.empty === next) return;
      store.empty = next;
      notifyRoot();
    },
    focus(key: string): void {
      store.focused = key;
      store.setTabStop(key);
    },
    forgetFocus(): void {
      store.focused = null;
    },
    /** Whether these shared values are news: each set of them is said once. */
    report(values: string): boolean {
      if (store.reported === values) return false;
      store.reported = values;
      return values !== "";
    },
    markRead(): void {
      store.stale = false;
    },
    oweFocus(): void {
      store.focusOwed = true;
    },
    payFocus(): void {
      store.focusOwed = false;
    },
    /** Items call this as they mount, unmount or change; one render answers however many did. */
    invalidate(): void {
      store.changes += 1;
      if (store.stale) return;
      store.stale = true;
      onItemsChange();
    },
  };
  return store;
}

type ListStore = ReturnType<typeof createListStore>;

interface ItemHandlers {
  /** Whether the pointer has moved since it was last heard: a list scrolling under a resting pointer has not. */
  readonly pointerMoved: (event: PointerEvent) => boolean;
  readonly onItemPointerDown: (event: PointerEvent<HTMLElement>, key: string) => void;
  readonly onItemPointerUp: (event: PointerEvent, key: string) => void;
  readonly onItemClick: (event: MouseEvent, key: string) => void;
  readonly onItemDoubleClick: (key: string) => void;
}

interface ListboxContextValue {
  readonly scope: string;
  readonly store: ListStore;
  readonly focusFollowsPointer: boolean;
  /** As of the last render. Read when an event arrives, so the context โ€” and every option โ€” stays as it is. */
  readonly handlers: RefObject<ItemHandlers | null>;
}

const ListboxContext = createContext<ListboxContextValue | null>(null);

function useListboxContext(): ListboxContextValue {
  const context = use(ListboxContext);
  if (context === null) throw new Error("ListboxItem must be rendered inside a Listbox.");
  return context;
}

/** A value read in the list's mode: one that allows a single option holds the first of several. */
function toSet(value: Value, multiple: boolean): ReadonlySet<string> {
  if (value === null) return new Set();
  if (typeof value === "string") return new Set([value]);
  return new Set(multiple ? value : value.slice(0, 1));
}

/** Where an option lies along the list, in an order that grows with the options. */
function extentOf(element: HTMLElement, horizontal: boolean, rtl: boolean): Extent {
  const box = element.getBoundingClientRect();
  if (!horizontal) return { start: box.top, end: box.bottom };
  return rtl ? { start: -box.right, end: -box.left } : { start: box.left, end: box.right };
}

export function Listbox({
  selectionMode = "single",
  value,
  defaultValue,
  onValueChange,
  selectionBehavior = "replace",
  disallowEmptySelection = false,
  orientation = "vertical",
  onAction,
  focusFollowsPointer = false,
  emptyState,
  className,
  children,
  ref,
  onKeyDown,
  onFocus,
  onBlur,
  onPointerDownCapture,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onLostPointerCapture,
  onContextMenu,
  "aria-describedby": describedBy,
  ...props
}: ListboxProps): ReactElement {
  const scope = useId();
  const emptyId = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const mergedRef = useMemo(() => mergeRefs(rootRef, ref), [ref]);
  const multiple = selectionMode === "multiple";
  const horizontal = orientation === "horizontal";

  const [rawValue, setRawValue] = useControllableState<Value>(
    value,
    defaultValue ?? (multiple ? [] : null),
    onValueChange as ((value: Value) => void) | undefined,
  );
  const selected = useMemo(() => toSet(rawValue, multiple), [rawValue, multiple]);

  // The mode changed under a value in the other mode's form: it is given back in this one's.
  useLayoutEffect(() => {
    if (multiple === Array.isArray(rawValue)) return;
    if (typeof rawValue === "string") setRawValue([rawValue]);
    else setRawValue(rawValue === null ? [] : (rawValue[0] ?? null));
  }, [multiple, rawValue, setRawValue]);

  // Where a range starts and what it adds to; see primitives/selection.ts.
  const range = useRef<Pick<SelectionState, "anchor" | "base">>({ anchor: null, base: new Set() });
  const [itemsVersion, onItemsChange] = useReducer((version: number) => version + 1, 0);
  const [store] = useState(() => createListStore(selected, onItemsChange));
  const rootState = useSyncExternalStore(store.subscribeRoot, store.rootState, store.rootState);
  const empty = (rootState & EMPTY) !== 0;

  /** The options as the document has them now. One reading serves an interaction. */
  function read(): Reading {
    const all = getCollection(rootRef.current, scope);
    return { all, enabled: all.filter((item) => !item.disabled) };
  }

  /**
   * One option is the tab stop: the one last focused, else the first selected,
   * else the first. Which one depends on the order, which only the document has.
   */
  const resolveTabStop = useCallback(() => {
    const all = getCollection(rootRef.current, scope);
    const enabled = all.filter((item) => !item.disabled);
    store.setEmpty(all.length === 0);
    if (process.env.NODE_ENV !== "production" && store.stale) {
      const values = duplicateKeys(all)
        .map((key) => JSON.stringify(key))
        .join(", ");
      if (store.report(values)) {
        console.warn(
          `Listbox: more than one option has the value ${values}. ` +
            "Options that share a value are selected together; give each its own.",
        );
      }
    }
    let next = enabled.find((item) => item.key === store.focused);
    if (next === undefined) {
      store.forgetFocus();
      next = enabled.find((item) => store.selected.has(item.key)) ?? enabled[0];
    }
    store.setTabStop(next?.key ?? null);
    if (store.focusOwed) {
      store.payFocus();
      (next?.element ?? rootRef.current)?.focus({ preventScroll: true });
    }
  }, [scope, store]);

  const resolvedFor = useRef<ReadonlySet<string> | null>(null);
  useLayoutEffect(() => {
    store.setSelected(selected);
    // While focus is on an option the tab stop is that option, and focus keeps it
    // so: the collection is read again only when it changed, or โ€” with focus
    // elsewhere โ€” when the selection the tab stop follows did.
    const unfocused = store.focused === null && resolvedFor.current !== selected;
    resolvedFor.current = selected;
    if (!store.stale && !unfocused) return;
    resolveTabStop();
    store.markRead();
    // `itemsVersion` is not read: it is the signal that items mounted or unmounted,
    // or that a selection was shown ahead of its value.
    // oxlint-disable-next-line react/exhaustive-effect-dependencies
  }, [store, selected, itemsVersion, resolveTabStop]);

  /**
   * Every key a selection may hold, in the order to report them: the options as
   * they stand, then whatever is selected but not among them โ€” an option that is
   * unavailable or not drawn stays selected until the person deselects it.
   */
  function reportOrder({ all }: Reading): string[] {
    const keys = all.map((item) => item.key);
    const present = new Set(keys);
    for (const key of selected) if (!present.has(key)) keys.push(key);
    return keys;
  }

  function select(intent: SelectionIntent, key: string, reading: Reading = read()): void {
    const next = applyIntent({ selected, ...range.current }, intent, key, {
      mode: selectionMode,
      order: reading.enabled.map((item) => item.key),
      disallowEmpty: disallowEmptySelection,
    });
    range.current = { anchor: next.anchor, base: next.base };
    if (sameSelection(next.selected, selected)) return;
    // Shown at once, so the options it changes are drawn in the same pass as
    // everything else this interaction changed; with so many options a second
    // pass is felt. The render asked for here puts back what an owner refuses.
    store.setSelected(next.selected);
    onItemsChange();
    setRawValue(multiple ? inOrder(next.selected, reportOrder(reading)) : ([...next.selected][0] ?? null));
  }

  /** The option that has focus, among those read: the element, since a value may not be one option's own. */
  function focusedItem(enabled: readonly CollectionItem[]): CollectionItem | null {
    const root = rootRef.current;
    const element = root === null ? null : closestItemElement(document.activeElement, root, scope);
    return (
      enabled.find((item) => (element === null ? item.key === store.focused : item.element === element)) ??
      null
    );
  }

  function onQuery(query: string): void {
    const reading = read();
    const match = matchTypeahead(
      reading.enabled.map((item) => ({
        key: item.key,
        // Read as the search reaches it, and no further than its match.
        get text() {
          return item.textValue;
        },
      })),
      query,
      store.focused,
    );
    const item = match === null ? undefined : reading.enabled.find((candidate) => candidate.key === match);
    if (item === undefined) return;
    focusItem(item.element);
    if (selectionBehavior === "replace") select("replace", item.key, reading);
  }

  const typeahead = useTypeahead(onQuery);

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
    if (typeahead(event)) {
      // A character of a query is nobody else's key: not a popup's that closes on Space.
      event.preventDefault();
      event.stopPropagation();
      return;
    }
    const command = event.metaKey || event.ctrlKey;
    const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
    const move = moveForKey(event.key, orientation, rtl ? "rtl" : "ltr");
    const page = event.key === "PageDown" ? 1 : event.key === "PageUp" ? -1 : 0;

    if (move !== null || page !== 0) {
      event.preventDefault();
      const reading = read();
      const { enabled } = reading;
      const from = focusedItem(enabled);
      const target =
        move !== null
          ? step(enabled, from, move)
          : (enabled[
              pageStep(
                enabled.length,
                from === null ? -1 : enabled.indexOf(from),
                page as 1 | -1,
                (index) => extentOf(enabled[index]!.element, horizontal, rtl),
                horizontal ? event.currentTarget.clientWidth : event.currentTarget.clientHeight,
              )
            ] ?? null);
      if (target === null) return;
      const focused = store.focused;
      focusItem(target.element);
      if (command && !event.shiftKey) return;
      // A selection that no gesture began โ€” given as a value โ€” extends from where focus was.
      if (multiple && event.shiftKey && range.current.anchor === null && focused !== null) {
        range.current = { anchor: focused, base: new Set(selected) };
      }
      if (multiple && event.shiftKey) select("extend", target.key, reading);
      else if (selectionBehavior === "replace") select("replace", target.key, reading);
      return;
    }

    const focused = store.focused;
    if (focused === null) return;
    if (event.key === " ") {
      event.preventDefault();
      if (multiple) select(event.shiftKey ? "extend" : "toggle", focused);
      else select(command ? "toggle" : "replace", focused);
    } else if (event.key === "Enter" && onAction !== undefined) {
      event.preventDefault();
      // A key held down acts once.
      if (!event.repeat) onAction(focused);
    } else if (command && event.key.toLowerCase() === "a") {
      // In a list of one choice there is nothing to select all of โ€” least of all the page's text.
      event.preventDefault();
      if (!multiple) return;
      const reading = read();
      const next = selectAll(
        { selected, ...range.current },
        reading.enabled.map((item) => item.key),
      );
      const all = new Set([...selected, ...next.selected]);
      if (!sameSelection(all, selected)) setRawValue(inOrder(all, reportOrder(reading)));
    }
  }

  // A pointer type is only known on pointerdown, and a touch selects on click so a
  // scroll that starts on an item does not select it.
  const pointerType = useRef("mouse");
  // A press on one of several selected options: it becomes the only one when the
  // press ends as a click, so that a drag can begin from it with them all.
  const deferred = useRef<string | null>(null);
  // Focus that a press brings must not scroll the list from under the pointer.
  const pressed = useRef(false);
  const pointerAt = useRef({ x: Number.NaN, y: Number.NaN });
  const press = useRef<Press | null>(null);
  // The click that ends a drag or a long press is the end of that gesture, not a click.
  const gestured = useRef(false);

  function intentOf(event: PointerEvent | MouseEvent, type: string): SelectionIntent {
    return pointerIntent(
      { shiftKey: event.shiftKey, commandKey: isCommandKey(event), pointerType: type },
      selectionMode,
      selectionBehavior,
    );
  }

  /** In a popup, an option is chosen: its action if the list has one, else its selection. */
  function choose(event: PointerEvent | MouseEvent, key: string): void {
    if (onAction !== undefined) onAction(key);
    else select(intentOf(event, pointerType.current), key);
  }

  function endPress(cancelled = false): void {
    const ended = press.current;
    if (ended === null) return;
    press.current = null;
    clearTimeout(ended.timer);
    if (ended.drag === null) return;
    cancelAnimationFrame(ended.drag.frame);
    const root = rootRef.current;
    if (root?.hasPointerCapture(ended.pointerId)) root.releasePointerCapture(ended.pointerId);
    // A drag the platform took back โ€” to scroll with a pen, say โ€” selected nothing.
    if (cancelled) {
      range.current = ended.before.range;
      setRawValue(ended.before.value);
    }
  }

  function beginDrag(from: Press, event: PointerEvent): void {
    const root = rootRef.current;
    if (root === null) return;
    // Held by the list, the drag goes on wherever the pointer does.
    root.setPointerCapture(from.pointerId);
    gestured.current = true;
    // A pen selects nothing as it presses, since its press may yet be a scroll.
    if (from.pointerType === "pen") flushSync(() => select("toggle", from.key));
    from.drag = {
      x: event.clientX,
      y: event.clientY,
      out: 0,
      reading: null,
      readAt: 0,
      reached: from.key,
      frame: 0,
      time: 0,
      carry: 0,
      rtl: getComputedStyle(root).direction === "rtl",
    };
  }

  /** Extend the range to the option the pointer is at, and keep the list scrolling while it is past an edge. */
  function dragStep(current: Press, now: number): void {
    const root = rootRef.current;
    const drag = current.drag;
    if (root === null || drag === null) return;
    if (drag.reading === null || drag.readAt !== store.changes) {
      drag.reading = read();
      drag.readAt = store.changes;
    }
    const { reading } = drag;
    const { enabled } = reading;
    const box = root.getBoundingClientRect();
    const low = horizontal ? box.left + root.clientLeft : box.top + root.clientTop;
    const high = low + (horizontal ? root.clientWidth : root.clientHeight);
    const at = horizontal ? drag.x : drag.y;
    drag.out = at < low ? at - low : at > high ? at - high : 0;
    // Past an edge, the range reaches the option at that edge.
    const within = Math.min(Math.max(at, low), high - 1);
    const sign = horizontal && drag.rtl ? -1 : 1;
    const target =
      enabled[
        itemAt(
          enabled.length,
          (index) => extentOf(enabled[index]!.element, horizontal, drag.rtl),
          sign * within,
        )
      ];
    if (target !== undefined && target.key !== drag.reached) {
      drag.reached = target.key;
      // At once: the next frame works from the selection this one made.
      flushSync(() => select("extend", target.key, reading));
      target.element.focus({ preventScroll: true });
    }
    if (drag.out !== 0 && drag.frame === 0) {
      drag.time = now;
      drag.frame = requestAnimationFrame((time) => latest.current?.dragFrame(time));
    }
  }

  function dragFrame(now: number): void {
    const current = press.current;
    const root = rootRef.current;
    const drag = current?.drag;
    if (current === null || root === null || !drag) return;
    drag.frame = 0;
    if (drag.out === 0) return;
    const elapsed = Math.min(Math.max(now - drag.time, 0), 64) / 1000;
    drag.carry += Math.sign(drag.out) * autoScrollSpeed(Math.abs(drag.out)) * elapsed;
    const whole = Math.trunc(drag.carry);
    drag.carry -= whole;
    if (horizontal) root.scrollLeft += whole;
    else root.scrollTop += whole;
    dragStep(current, now);
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>): void {
    const current = press.current;
    if (current === null || current.pointerId !== event.pointerId) return;
    // Let go where the list could not hear it.
    if (event.buttons === 0) {
      endPress();
      return;
    }
    if (current.drag === null) {
      const distance = Math.hypot(event.clientX - current.x, event.clientY - current.y);
      if (current.pointerType === "touch" && !current.longPressed) {
        // A finger that moves before a long press is scrolling.
        if (distance > TOUCH_SLOP) clearTimeout(current.timer);
        return;
      }
      if (distance < DRAG_THRESHOLD || !current.drawsRange) return;
      beginDrag(current, event);
    }
    if (current.drag === null) return;
    current.drag.x = event.clientX;
    current.drag.y = event.clientY;
    dragStep(current, performance.now());
  }

  /**
   * A long press by touch, heard as the platform's `contextmenu` โ€” which the
   * list raises itself where the platform does not. In a single selection it is
   * a secondary press: the option is selected, and a menu may open on it. In a
   * multiple selection it begins selecting: the option is added and, while the
   * finger stays down, a range follows it.
   */
  function handleContextMenu(event: MouseEvent<HTMLDivElement>): void {
    const current = press.current;
    if (current === null || current.pointerType !== "touch" || current.longPressed) return;
    current.longPressed = true;
    clearTimeout(current.timer);
    gestured.current = true;
    current.element.focus({ preventScroll: true });
    if (!multiple) {
      if (!selected.has(current.key)) select("replace", current.key);
      return;
    }
    // Kept from the platform, whose own menu would end the press the range needs.
    event.preventDefault();
    if (current.drawsRange) select("toggle", current.key);
  }

  const handlers: ItemHandlers = {
    pointerMoved(event) {
      const moved = event.clientX !== pointerAt.current.x || event.clientY !== pointerAt.current.y;
      pointerAt.current = { x: event.clientX, y: event.clientY };
      return moved;
    },
    onItemPointerDown(event, key) {
      endPress();
      pointerType.current = event.pointerType;
      deferred.current = null;
      gestured.current = false;
      const mouse = event.pointerType === "mouse";
      // A secondary press โ€” on a Mac, Ctrl-click too โ€” is for a context menu: it
      // selects what it lands on only if that is outside the selection.
      if (mouse && (event.button === 2 || (event.button === 0 && event.ctrlKey && !isCommandKey(event)))) {
        if (!selected.has(key)) select("replace", key);
        return;
      }
      // In a popup, the release chooses.
      if (event.button !== 0 || focusFollowsPointer) return;
      const began: Press = {
        pointerId: event.pointerId,
        pointerType: event.pointerType,
        key,
        element: event.currentTarget,
        x: event.clientX,
        y: event.clientY,
        drawsRange: multiple && !selected.has(key),
        before: { value: rawValue, range: range.current },
        timer: undefined,
        longPressed: false,
        drag: null,
      };
      press.current = began;
      if (event.pointerType === "touch") {
        began.timer = setTimeout(() => {
          if (press.current !== began || began.longPressed) return;
          began.element.dispatchEvent(
            new globalThis.MouseEvent("contextmenu", {
              bubbles: true,
              cancelable: true,
              clientX: began.x,
              clientY: began.y,
            }),
          );
        }, LONG_PRESS);
      }
      // A finger or a pen selects on click: its press may yet be a scroll.
      if (!mouse) return;
      const intent = intentOf(event, "mouse");
      if (intent === "replace" && multiple && selected.size > 1 && selected.has(key)) {
        deferred.current = key;
        return;
      }
      select(intent, key);
    },
    onItemPointerUp(event, key) {
      if (focusFollowsPointer && event.pointerType === "mouse" && event.button === 0) choose(event, key);
    },
    onItemClick(event, key) {
      // A click that no press came before is a key's, or assistive technology's.
      if (event.detail === 0) {
        if (focusFollowsPointer && onAction !== undefined) onAction(key);
        else select(multiple ? "toggle" : "replace", key);
        return;
      }
      if (gestured.current) return;
      if (pointerType.current === "mouse") {
        if (deferred.current === key) select("replace", key);
        deferred.current = null;
        return;
      }
      if (focusFollowsPointer) choose(event, key);
      else if (
        onAction !== undefined &&
        !event.shiftKey &&
        !isCommandKey(event) &&
        tapActs(selected, key, selectionMode, selectionBehavior)
      ) {
        onAction(key);
      } else select(intentOf(event, pointerType.current), key);
    },
    onItemDoubleClick(key) {
      // Two taps have each been heard as a tap.
      if (pointerType.current === "mouse") onAction?.(key);
    },
  };

  const latest = useRef<(ItemHandlers & { dragFrame: typeof dragFrame }) | null>(null);
  useLayoutEffect(() => {
    latest.current = { ...handlers, dragFrame };
  });

  useEffect(() => {
    const root = rootRef.current;
    if (root === null || focusFollowsPointer) return;
    const abort = new AbortController();
    // A long press the list has already raised is not raised again by the platform.
    root.addEventListener(
      "contextmenu",
      (event) => {
        if (!event.isTrusted || press.current?.longPressed !== true) return;
        event.preventDefault();
        event.stopPropagation();
      },
      { capture: true, signal: abort.signal },
    );
    if (multiple) {
      // After a long press the finger draws a range, and the list must not scroll under it.
      root.addEventListener(
        "touchmove",
        (event) => {
          if (press.current?.longPressed === true && event.cancelable) event.preventDefault();
        },
        { passive: false, signal: abort.signal },
      );
    }
    return () => abort.abort();
  }, [multiple, focusFollowsPointer]);

  useEffect(
    () => () => {
      clearTimeout(press.current?.timer);
      cancelAnimationFrame(press.current?.drag?.frame ?? 0);
    },
    [],
  );

  const context = useMemo<ListboxContextValue>(
    () => ({ scope, store, focusFollowsPointer, handlers: latest }),
    [scope, store, focusFollowsPointer],
  );

  const showEmpty = empty && emptyState !== undefined && emptyState !== null;

  return (
    <ListboxContext value={context}>
      <div
        ref={mergedRef}
        role="listbox"
        aria-multiselectable={multiple || undefined}
        aria-orientation={orientation}
        aria-describedby={showEmpty ? [describedBy, emptyId].filter(Boolean).join(" ") : describedBy}
        // Focusable itself only until it has an item to hand focus to.
        tabIndex={(rootState & NO_TAB_STOP) !== 0 ? 0 : -1}
        data-orientation={orientation}
        data-popup={focusFollowsPointer ? "" : undefined}
        data-empty={empty ? "" : undefined}
        onKeyDown={(event) => {
          onKeyDown?.(event);
          pressed.current = false;
          if (!event.defaultPrevented) handleKeyDown(event);
        }}
        onPointerDownCapture={(event) => {
          onPointerDownCapture?.(event);
          pressed.current = true;
        }}
        onPointerMove={(event) => {
          onPointerMove?.(event);
          if (!event.defaultPrevented) handlePointerMove(event);
        }}
        onPointerUp={(event) => {
          onPointerUp?.(event);
          if (press.current?.pointerId === event.pointerId) endPress();
        }}
        onPointerCancel={(event) => {
          onPointerCancel?.(event);
          if (press.current?.pointerId === event.pointerId) endPress(true);
        }}
        onLostPointerCapture={(event) => {
          onLostPointerCapture?.(event);
          // The list's own capture: a finger's capture passing from the option to the list is not its end.
          if (event.target !== event.currentTarget) return;
          if (press.current?.pointerId === event.pointerId && press.current.drag !== null) endPress();
        }}
        onContextMenu={(event) => {
          onContextMenu?.(event);
          handleContextMenu(event);
        }}
        onFocus={(event) => {
          onFocus?.(event);
          if (event.defaultPrevented || event.target !== event.currentTarget) return;
          const item =
            store.tabStop === null ? null : getCollectionItem(event.currentTarget, scope, store.tabStop);
          if (item === null) return;
          if (pressed.current) item.element.focus({ preventScroll: true });
          else focusItem(item.element);
        }}
        onBlur={(event) => {
          onBlur?.(event);
          if (event.currentTarget.contains(event.relatedTarget)) return;
          // What is typed on return is a new query.
          typeahead.reset();
          // A popup opens each time on what is chosen, not on what was last pointed at.
          if (focusFollowsPointer) {
            store.forgetFocus();
            resolveTabStop();
          }
        }}
        className={cn(
          "group/listbox flex flex-col overflow-auto overscroll-contain rounded-lg border border-border-muted bg-surface p-1 select-none",
          // A long press is the list's own: not the platform's callout over the text.
          "[-webkit-touch-callout:none]",
          "data-[orientation=horizontal]:flex-row",
          focusRingClassName,
          className,
        )}
        {...props}
      >
        {children}
        {showEmpty && (
          // Read out as the list's description; as content, a list may hold only options.
          <div id={emptyId} aria-hidden className="px-2 py-1 text-sm text-fg-muted">
            {emptyState}
          </div>
        )}
      </div>
    </ListboxContext>
  );
}

export type ListboxItemProps = ComponentProps<"div"> & {
  value: string;
  disabled?: boolean;
  /** What typeahead matches, when the item's text is not the right answer. */
  textValue?: string;
};

export function ListboxItem({
  value,
  disabled = false,
  textValue,
  className,
  onFocus,
  onPointerMove,
  onPointerDown,
  onPointerUp,
  onClick,
  onDoubleClick,
  children,
  ref,
  ...props
}: ListboxItemProps): ReactElement {
  const { scope, store, handlers, focusFollowsPointer: popup } = useListboxContext();
  const subscribe = useCallback((listener: () => void) => store.subscribe(value, listener), [store, value]);
  const snapshot = (): number => store.itemState(value);
  const state = useSyncExternalStore(subscribe, snapshot, snapshot);
  const selected = (state & SELECTED) !== 0;
  const element = useRef<HTMLDivElement>(null);
  const mergedRef = useMemo(() => mergeRefs(element, ref), [ref]);

  // Told while the element still holds focus: before it is taken out, or as it
  // becomes unavailable.
  useLayoutEffect(() => {
    const node = element.current;
    const owe = (): void => {
      if (document.activeElement === node) store.oweFocus();
    };
    if (disabled) owe();
    return owe;
  }, [disabled, store]);

  // Changing `value` or `disabled` changes which item can be the tab stop.
  useLayoutEffect(() => {
    store.invalidate();
    return store.invalidate;
    // oxlint-disable-next-line react/exhaustive-effect-dependencies
  }, [store, value, disabled]);

  return (
    // Keys are handled by the listbox, which owns focus movement for every option.
    // oxlint-disable-next-line jsx-a11y/click-events-have-key-events
    <div
      ref={mergedRef}
      role="option"
      aria-selected={selected}
      aria-disabled={disabled || undefined}
      tabIndex={disabled ? undefined : (state & TAB_STOP) !== 0 ? 0 : -1}
      {...collectionItemProps(scope, value, { disabled, textValue })}
      data-selected={selected ? "" : undefined}
      onFocus={composeEventHandlers(onFocus, () => store.focus(value))}
      onPointerMove={composeEventHandlers(onPointerMove, (event) => {
        if (!popup || disabled || event.pointerType === "touch" || !handlers.current?.pointerMoved(event))
          return;
        if (document.activeElement !== event.currentTarget)
          event.currentTarget.focus({ preventScroll: true });
      })}
      onPointerDown={composeEventHandlers(onPointerDown, (event) => {
        if (!disabled) handlers.current?.onItemPointerDown(event, value);
      })}
      onPointerUp={composeEventHandlers(onPointerUp, (event) => {
        if (!disabled) handlers.current?.onItemPointerUp(event, value);
      })}
      onClick={composeEventHandlers(onClick, (event) => {
        if (!disabled) handlers.current?.onItemClick(event, value);
      })}
      onDoubleClick={composeEventHandlers(onDoubleClick, () => {
        if (!disabled) handlers.current?.onItemDoubleClick(value);
      })}
      className={cn(
        "relative flex min-h-8 cursor-default items-center gap-2 rounded-md px-2 py-1 text-sm text-fg outline-none",
        "[-webkit-tap-highlight-color:transparent] forced-colors:forced-color-adjust-none",
        // A finger needs a larger target than a pointer; the list grows rather than the text.
        "pointer-coarse:min-h-11",
        popup
          ? // In a popup the fill is the highlight, which follows focus; the choice is the mark.
            "ps-7 focus:bg-selected focus:text-on-selected forced-colors:focus:bg-[Highlight] forced-colors:focus:text-[HighlightText]"
          : [
              "hover:bg-surface-muted",
              // A selection reads as the list's accent only while the list has focus.
              "data-selected:bg-surface-strong data-selected:text-fg-strong",
              "group-focus-within/listbox:data-selected:bg-selected group-focus-within/listbox:data-selected:text-on-selected",
              // Adjacent selected items join into one block, along the way the list runs.
              "group-data-[orientation=vertical]/listbox:[&[data-selected]:has(+[data-selected])]:rounded-b-none group-data-[orientation=vertical]/listbox:[[data-selected]+&[data-selected]]:rounded-t-none",
              "group-data-[orientation=horizontal]/listbox:[&[data-selected]:has(+[data-selected])]:rounded-e-none group-data-[orientation=horizontal]/listbox:[[data-selected]+&[data-selected]]:rounded-s-none",
              "forced-colors:data-selected:bg-[Highlight] forced-colors:data-selected:text-[HighlightText]",
              "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus-ring focus-visible:outline-solid",
              // On the accent, the ring is the colour of the text there.
              "group-focus-within/listbox:data-selected:focus-visible:outline-on-selected",
              // Selected, yet not to be had: neither the accent nor the look of a choice on offer.
              "data-disabled:data-selected:bg-surface-strong data-disabled:data-selected:text-fg-muted",
            ],
        "data-disabled:text-fg-disabled data-disabled:hover:bg-transparent forced-colors:data-disabled:text-[GrayText]",
        "pointer-coarse:text-base",
        className,
      )}
      {...props}
    >
      {popup && selected && (
        <svg
          aria-hidden
          viewBox="0 0 16 16"
          className="absolute start-2 size-3.5"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
        >
          <path d="M3.5 8.5 6.5 11.5 12.5 4.5" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      )}
      {children}
    </div>
  );
}

export type ListboxGroupProps = Omit<ComponentProps<"div">, "children"> & {
  label: ReactNode;
  children?: ReactNode;
};

export function ListboxGroup({ label, className, children, ...props }: ListboxGroupProps): ReactElement {
  const labelId = useId();
  return (
    <div role="group" aria-labelledby={labelId} className={cn("flex flex-col", className)} {...props}>
      <div id={labelId} role="presentation" className="px-2 pt-2 pb-1 text-xs font-medium text-fg-muted">
        {label}
      </div>
      {children}
    </div>
  );
}