diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 405387cbda..173eb90e6a 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -108,6 +108,23 @@ is convention. - `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call `loader.config()` in v1: the loader is shared with v0. +## Selection inputs + +- Dropdowns, searchable pickers, chip inputs and segmented switches come + from `@v1/components/ui/select` (`Select`, `Combobox`, `MultiSelect`, + `SegmentedControl`, `Chip`); its README lists every prop and the keyboard. + Do not build another dropdown, picker or chip input. +- Pick by list size: `Select` for a short, known list (under about 50 + options; it renders every one), `Combobox` for a long or server-loaded + list (`loadOptions`), `MultiSelect` for several values, and + `SegmentedControl` for 2 to 5 always-visible options. +- `Select`, `Combobox` and `MultiSelect` take `SelectOption`, and + `SegmentedControl` takes `SegmentedOption`; the folder README lists their + fields. A provider or connector logo goes in a `SelectOption`'s `leading` + as `` from `@v1/components/ui/BrandMark`. +- Until P1-T06's `Field` lands, name them with `aria-label` or + `aria-labelledby`; do not put them inside `Field`. + ## Version switch and route registry v0's router (`routes/Router.jsx`) wraps its routes in `VersionSwitch`, which diff --git a/frontend/src/v1/components/ui/select/Chip.test.tsx b/frontend/src/v1/components/ui/select/Chip.test.tsx new file mode 100644 index 0000000000..aa91156034 --- /dev/null +++ b/frontend/src/v1/components/ui/select/Chip.test.tsx @@ -0,0 +1,45 @@ +import { screen } from "@testing-library/react"; +import { renderV1 } from "@v1/test"; +import { expect, it, vi } from "vitest"; +import { Chip } from "./Chip"; + +it("renders the label with a remove button named after it", async () => { + const onRemove = vi.fn(); + const { user } = renderV1(); + const chip = document.querySelector('[data-slot="chip"]'); + expect(chip?.textContent).toBe("GPT-4o"); + const remove = screen.getByRole("button", { name: "Remove GPT-4o" }); + expect(remove.getAttribute("type")).toBe("button"); + await user.click(remove); + expect(onRemove).toHaveBeenCalledTimes(1); +}); + +it("disables the remove button when the chip is disabled", async () => { + const onRemove = vi.fn(); + const { user } = renderV1( + , + ); + const remove = screen.getByRole("button", { name: "Remove GPT-4o" }); + expect((remove as HTMLButtonElement).disabled).toBe(true); + await user.click(remove); + expect(onRemove).not.toHaveBeenCalled(); +}); + +it("has no remove button without onRemove", () => { + renderV1(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("GPT-4o")).toBeTruthy(); +}); + +it("takes the remove button out of the tab order when not removeFocusable", async () => { + const onRemove = vi.fn(); + const { user, rerender } = renderV1( + , + ); + const remove = () => screen.getByRole("button", { name: "Remove GPT-4o" }); + expect(remove().tabIndex).toBe(0); + rerender(); + expect(remove().tabIndex).toBe(-1); + await user.click(remove()); + expect(onRemove).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/src/v1/components/ui/select/Chip.tsx b/frontend/src/v1/components/ui/select/Chip.tsx new file mode 100644 index 0000000000..3461a39972 --- /dev/null +++ b/frontend/src/v1/components/ui/select/Chip.tsx @@ -0,0 +1,55 @@ +import { cn } from "@v1/lib/cn"; +import { ACTION_ICONS } from "@v1/lib/icons"; +import type { JSX } from "react"; + +const RemoveIcon = ACTION_ICONS.close; + +export type ChipProps = Readonly<{ + label: string; + /** Shows a remove button named "Remove {label}". */ + onRemove?: () => void; + /** + * Whether the remove button is a tab stop. Default true; false keeps it + * clickable and named but out of the tab order, for a field whose keyboard + * removes chips another way (MultiSelect: Backspace). + */ + removeFocusable?: boolean; + disabled?: boolean; + className?: string; +}>; + +/** + * A selected value in a MultiSelect, with an optional remove button, which + * `removeFocusable={false}` takes out of the tab order. + */ +export function Chip({ + label, + onRemove, + removeFocusable = true, + disabled, + className, +}: ChipProps): JSX.Element { + return ( + + {label} + {onRemove ? ( + + ) : null} + + ); +} diff --git a/frontend/src/v1/components/ui/select/Combobox.test.tsx b/frontend/src/v1/components/ui/select/Combobox.test.tsx new file mode 100644 index 0000000000..924968b50a --- /dev/null +++ b/frontend/src/v1/components/ui/select/Combobox.test.tsx @@ -0,0 +1,579 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderV1, stubLayout } from "@v1/test"; +import { Star } from "lucide-react"; +import { expect, it, vi } from "vitest"; +import { Combobox, type ComboboxProps } from "./Combobox"; +import type { SelectOption } from "./options"; +import type { LoadOptions } from "./useAsyncOptions"; + +const MODELS: readonly SelectOption[] = [ + { value: "claude", label: "Claude", leading: }, + { value: "gemini", label: "Gemini", description: "Google" }, + { value: "grok", label: "Grok", disabled: true }, + { value: "gemma", label: "Gemma", keywords: ["gemma-3"] }, + { value: "llama", label: "Llama" }, +]; + +function input(): HTMLInputElement { + return screen.getByRole("combobox", { name: "Model" }) as HTMLInputElement; +} + +const optionNames = () => + screen.queryAllByRole("option").map((option) => option.textContent); + +function setup(props: Partial = {}) { + const onValueChange = vi.fn(); + const view = renderV1( + <> + + + , + ); + return { ...view, onValueChange }; +} + +/** A loader whose every call waits until the test settles it. */ +function deferredLoader() { + const calls: { + query: string; + resolve: (options: readonly SelectOption[]) => void; + reject: (error: unknown) => void; + }[] = []; + const load = vi.fn( + (query) => + new Promise((resolve, reject) => { + calls.push({ query, resolve, reject }); + }), + ); + return { load, calls }; +} + +const opt = (label: string): SelectOption => ({ + value: label.toLowerCase(), + label, +}); + +it("is a combobox named by aria-label that focus alone does not open", async () => { + const { user } = setup(); + expect(input().getAttribute("aria-autocomplete")).toBe("list"); + expect(input().placeholder).toBe("Pick a model"); + await user.tab(); + expect(document.activeElement).toBe(input()); + expect(input().getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByRole("listbox")).toBeNull(); +}); + +it("caps the list at the height free around the frame, on its one scroll element", async () => { + const { user } = setup(); + await user.click(input()); + const scroll = document.querySelector('[data-slot="listbox-scroll"]'); + expect(scroll?.className).toContain( + "ui:max-h-(--radix-popover-content-available-height)", + ); +}); + +it("opens on a click on the input, at the selected option", async () => { + const { user } = setup({ defaultValue: "gemma" }); + await user.click(input()); + expect(input().getAttribute("aria-expanded")).toBe("true"); + expect(input().getAttribute("aria-activedescendant")).toBe( + screen.getByRole("option", { name: "Gemma" }).id, + ); +}); + +it("selects the shown label on click and on focus, so typing replaces it", async () => { + const { user } = setup({ defaultValue: "gemma" }); + await user.click(input()); + await user.keyboard("c"); + expect(input().value).toBe("c"); + expect(optionNames()).toEqual(["Claude"]); + await user.keyboard("{Escape}"); + expect(input().value).toBe("Gemma"); + await user.click(screen.getByRole("button", { name: "Elsewhere" })); + await user.tab({ shift: true }); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(input()); + await user.keyboard("l"); + expect(input().value).toBe("l"); +}); + +it("opens and filters on typing, matching labels and keywords", async () => { + const { user } = setup(); + await user.type(input(), "gem"); + expect(input().getAttribute("aria-expanded")).toBe("true"); + expect(input().getAttribute("aria-controls")).toBe( + screen.getByRole("listbox", { name: "Model" }).id, + ); + expect(optionNames()).toEqual(["GeminiGoogle", "Gemma"]); + await user.clear(input()); + await user.type(input(), "-3"); + expect(optionNames()).toEqual(["Gemma"]); +}); + +it("opens on ArrowDown and selects the active option on Enter", async () => { + const { user, onValueChange } = setup(); + await user.tab(); + await user.keyboard("{ArrowDown}"); + expect(screen.getByRole("listbox")).toBeTruthy(); + await user.keyboard("{ArrowDown}"); + const gemini = screen.getByRole("option", { name: /Gemini/ }); + expect(input().getAttribute("aria-activedescendant")).toBe(gemini.id); + await user.keyboard("{Enter}"); + expect(onValueChange).toHaveBeenCalledWith("gemini", MODELS[1]); + expect(input().value).toBe("Gemini"); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(input().getAttribute("aria-expanded")).toBe("false"); +}); + +it("selects a clicked option and keeps focus on the input", async () => { + const { user, onValueChange } = setup(); + await user.type(input(), "ll"); + await user.click(screen.getByRole("option", { name: "Llama" })); + expect(onValueChange).toHaveBeenCalledWith("llama", MODELS[4]); + expect(input().value).toBe("Llama"); + expect(document.activeElement).toBe(input()); +}); + +it("shows the selected option's leading before the input", () => { + setup({ defaultValue: "claude" }); + const frame = input().closest('[data-slot="combobox"]') as HTMLElement; + expect(input().value).toBe("Claude"); + const leading = frame.querySelector('[data-slot="option-leading"]'); + expect(leading?.nextElementSibling).toBe(input()); +}); + +it("restores the selected label on Escape without changing the value", async () => { + const { user, onValueChange } = setup({ defaultValue: "llama" }); + await user.type(input(), "lx"); + expect(input().value).toBe("lx"); + expect(screen.getByRole("listbox")).toBeTruthy(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(input().value).toBe("Llama"); + expect(onValueChange).not.toHaveBeenCalled(); +}); + +it("empties the input on Escape when nothing is selected", async () => { + const { user } = setup(); + await user.type(input(), "gem"); + await user.keyboard("{Escape}"); + expect(input().value).toBe(""); + // The query is gone: reopening lists everything. + await user.keyboard("{ArrowDown}"); + expect(optionNames()).toHaveLength(MODELS.length); +}); + +it("closes and restores the label when focus leaves", async () => { + const { user } = setup({ defaultValue: "gemma" }); + await user.type(input(), "cl"); + expect(input().value).toBe("cl"); + expect(screen.getByRole("listbox")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Elsewhere" })); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(input().value).toBe("Gemma"); +}); + +it("stays open when the list's padding is pressed", async () => { + const { user } = setup(); + await user.type(input(), "gem"); + await user.click( + document.querySelector('[data-slot="listbox-scroll"]') as HTMLElement, + ); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(document.activeElement).toBe(input()); + expect(input().value).toBe("gem"); +}); + +it("stays open when the input is pressed again", async () => { + const { user } = setup(); + await user.type(input(), "gem"); + await user.click(input()); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(input().value).toBe("gem"); +}); + +it("restores the label when Tab moves to the clear button", async () => { + const { user } = setup({ defaultValue: "gemma" }); + await user.type(input(), "cl"); + expect(input().value).toBe("cl"); + await user.tab(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Clear" }), + ); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(input().value).toBe("Gemma"); +}); + +it("points aria-activedescendant at a new id when the first match changes", async () => { + const { user } = setup({ options: [opt("Cat"), opt("Crab")] }); + await user.type(input(), "a"); + const cat = screen.getByRole("option", { name: "Cat" }); + expect(input().getAttribute("aria-activedescendant")).toBe(cat.id); + await user.type(input(), "b"); + const crab = screen.getByRole("option", { name: "Crab" }); + expect(input().getAttribute("aria-activedescendant")).toBe(crab.id); + expect(crab.id).not.toBe(cat.id); +}); + +it("jumps to the first and last enabled option on Home and End", async () => { + const { user } = setup(); + await user.tab(); + await user.keyboard("{ArrowDown}{End}"); + expect(input().getAttribute("aria-activedescendant")).toBe( + screen.getByRole("option", { name: "Llama" }).id, + ); + await user.keyboard("{Home}"); + expect(input().getAttribute("aria-activedescendant")).toBe( + screen.getByRole("option", { name: "Claude" }).id, + ); +}); + +it("never activates a disabled option, and Enter on one does nothing", async () => { + const { user, onValueChange } = setup(); + await user.tab(); + await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); + expect(input().getAttribute("aria-activedescendant")).toBe( + screen.getByRole("option", { name: "Gemma" }).id, + ); + await user.clear(input()); + await user.type(input(), "grok"); + expect(optionNames()).toEqual(["Grok"]); + expect(input().hasAttribute("aria-activedescendant")).toBe(false); + await user.keyboard("{Enter}"); + await user.click(screen.getByRole("option", { name: "Grok" })); + expect(onValueChange).not.toHaveBeenCalled(); + expect(screen.getByRole("listbox")).toBeTruthy(); +}); + +it("keeps Enter from submitting the form while the list is open", async () => { + const onSubmit = vi.fn((event: { preventDefault: () => void }) => + event.preventDefault(), + ); + const { user } = renderV1( +
+ + + , + ); + await user.type(input(), "zzz"); + expect(screen.getByRole("status").textContent).toBe("No matches"); + await user.keyboard("{Enter}"); + expect(onSubmit).not.toHaveBeenCalled(); + await user.keyboard("{Escape}{Enter}"); + expect(onSubmit).toHaveBeenCalledTimes(1); +}); + +it("clears the value with the clear button", async () => { + const { user, onValueChange } = setup({ defaultValue: "gemini" }); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onValueChange).toHaveBeenCalledWith(null, null); + expect(input().value).toBe(""); + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + expect(document.activeElement).toBe(input()); +}); + +it("hides the clear button with no value or when disabled", () => { + const { unmount } = setup(); + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + unmount(); + setup({ defaultValue: "gemini", disabled: true }); + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); +}); + +it("toggles the list with the chevron, keeping focus on the input", async () => { + const { user } = setup({ defaultValue: "gemma" }); + const chevron = screen.getByRole("button", { name: "Show options" }); + expect(chevron.tabIndex).toBe(-1); + expect(chevron.getAttribute("aria-controls")).toBe( + input().getAttribute("aria-controls"), + ); + await user.click(chevron); + expect(document.activeElement).toBe(input()); + expect(chevron.getAttribute("aria-expanded")).toBe("true"); + expect(input().getAttribute("aria-activedescendant")).toBe( + screen.getByRole("option", { name: "Gemma" }).id, + ); + await user.click(chevron); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(chevron.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(input()); +}); + +it("follows a controlled value", async () => { + const { user, onValueChange, rerender } = setup({ value: "claude" }); + await user.tab(); + await user.keyboard("{ArrowDown}{End}{Enter}"); + expect(onValueChange).toHaveBeenCalledWith("llama", MODELS[4]); + expect(input().value).toBe("Claude"); + rerender(); + expect(input().value).toBe("Gemma"); +}); + +it("submits the value through a hidden input when named", async () => { + const { user } = setup({ name: "model", defaultValue: "gemini" }); + const hidden = document.querySelector( + 'input[type="hidden"][name="model"]', + ); + expect(hidden?.value).toBe("gemini"); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(hidden?.value).toBe(""); +}); + +it("omits the hidden input when disabled", () => { + setup({ name: "model", defaultValue: "gemini", disabled: true }); + expect(document.querySelector('input[type="hidden"]')).toBeNull(); +}); + +it("passes id, invalid, disabled and aria-describedby to the input", () => { + setup({ + id: "model-input", + invalid: true, + disabled: true, + "aria-describedby": "model-help", + }); + const frame = input().closest('[data-slot="combobox"]') as HTMLElement; + expect(input().id).toBe("model-input"); + expect(input().getAttribute("aria-invalid")).toBe("true"); + expect(input().getAttribute("aria-describedby")).toBe("model-help"); + expect(input().disabled).toBe(true); + expect(frame.hasAttribute("data-invalid")).toBe(true); + expect(frame.className).toContain( + "ui:has-[[data-slot=control]:focus-visible]:focus-ring", + ); + expect(frame.className).not.toContain("ui:focus-within:focus-ring"); + expect(frame.hasAttribute("data-disabled")).toBe(true); + expect( + (screen.getByRole("button", { name: "Show options" }) as HTMLButtonElement) + .disabled, + ).toBe(true); +}); + +it("loads options when the list first opens and shows a loading row", async () => { + const { load, calls } = deferredLoader(); + const { user } = setup({ options: undefined, loadOptions: load }); + expect(load).not.toHaveBeenCalled(); + await user.tab(); + await user.keyboard("{ArrowDown}"); + await waitFor(() => expect(load).toHaveBeenCalledTimes(1)); + expect(calls[0].query).toBe(""); + expect(screen.getByRole("status").textContent).toBe("Loading…"); + expect(screen.getByRole("listbox").getAttribute("aria-busy")).toBe("true"); + await act(async () => calls[0].resolve([opt("Alpha"), opt("Beta")])); + expect(optionNames()).toEqual(["Alpha", "Beta"]); + expect(screen.queryByRole("status")).toBeNull(); + await user.keyboard("{ArrowDown}{Enter}"); + expect(input().value).toBe("Beta"); +}); + +it("shows the loading row the moment the list opens, never No options first", async () => { + const { load, calls } = deferredLoader(); + setup({ options: undefined, loadOptions: load }); + act(() => input().focus()); + // Synchronously, before the first load's 0 ms timer can run. + act(() => { + fireEvent.keyDown(input(), { key: "ArrowDown" }); + }); + expect(load).not.toHaveBeenCalled(); + expect(screen.getByRole("status").textContent).toBe("Loading…"); + await waitFor(() => expect(calls).toHaveLength(1)); +}); + +it("shows no stale options when the list reopens", async () => { + const { load, calls } = deferredLoader(); + const { user } = setup({ options: undefined, loadOptions: load }); + await user.tab(); + await user.keyboard("a"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([opt("Alpha")])); + expect(optionNames()).toEqual(["Alpha"]); + await user.keyboard("{Escape}"); + act(() => { + fireEvent.keyDown(input(), { key: "ArrowDown" }); + }); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(optionNames()).toEqual([]); + expect(screen.getByRole("status").textContent).toBe("Loading…"); + await waitFor(() => expect(calls).toHaveLength(2)); +}); + +it("shows the latest query's options when responses arrive out of order", async () => { + const { load, calls } = deferredLoader(); + const { user } = setup({ options: undefined, loadOptions: load }); + await user.tab(); + await user.keyboard("a"); + await waitFor(() => expect(calls).toHaveLength(1)); + await user.keyboard("b"); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls.map((call) => call.query)).toEqual(["a", "ab"]); + // The server's answer for "ab" need not contain "ab": it is not refiltered. + await act(async () => calls[1].resolve([opt("Server One")])); + await act(async () => calls[0].resolve([opt("Stale")])); + expect(optionNames()).toEqual(["Server One"]); +}); + +it("lets nothing be selected from the previous query while the next one waits", async () => { + const { load, calls } = deferredLoader(); + const { user, onValueChange } = setup({ + options: undefined, + loadOptions: load, + }); + await user.tab(); + await user.keyboard("a"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([opt("Alpha")])); + expect(optionNames()).toEqual(["Alpha"]); + // "az" is still waiting out its debounce: Alpha is gone, not merely stale. + await user.keyboard("z"); + expect(optionNames()).toEqual([]); + expect(screen.getByRole("status").textContent).toBe("Loading…"); + await user.keyboard("{Enter}"); + expect(onValueChange).not.toHaveBeenCalled(); + expect(input().value).toBe("az"); +}); + +it("says No matches when the loader returns nothing", async () => { + const { load, calls } = deferredLoader(); + const { user } = setup({ options: undefined, loadOptions: load }); + await user.tab(); + await user.keyboard("z"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([])); + expect(screen.getByRole("status").textContent).toBe("No matches"); +}); + +it("keeps a loaded selection's label after the query changes", async () => { + const { load, calls } = deferredLoader(); + const { user } = setup({ options: undefined, loadOptions: load }); + await user.tab(); + await user.keyboard("a"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([opt("Alpha")])); + await user.keyboard("{Enter}"); + expect(input().value).toBe("Alpha"); + await user.keyboard("z"); + await waitFor(() => expect(calls).toHaveLength(2)); + await act(async () => calls[1].resolve([])); + await user.keyboard("{Escape}"); + expect(input().value).toBe("Alpha"); +}); + +it("shows a load error instead of the previous options", async () => { + const { load, calls } = deferredLoader(); + const { user, onValueChange } = setup({ + options: undefined, + loadOptions: load, + }); + await user.tab(); + await user.keyboard("a"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([opt("Server One")])); + expect(optionNames()).toEqual(["Server One"]); + await user.keyboard("b"); + await waitFor(() => expect(calls).toHaveLength(2)); + await act(async () => calls[1].reject(new Error("boom"))); + expect(screen.getByRole("status").textContent).toBe("Could not load options"); + expect(screen.queryByRole("option", { name: "Server One" })).toBeNull(); + await user.keyboard("{Enter}"); + expect(onValueChange).not.toHaveBeenCalled(); +}); + +it("keeps the label of an option from an earlier response", async () => { + const { load, calls } = deferredLoader(); + const onValueChange = vi.fn(); + const view = renderV1( + , + ); + const { user } = view; + await user.tab(); + await user.keyboard("b"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([opt("Beta")])); + await user.clear(input()); + await user.type(input(), "a"); + await waitFor(() => expect(calls).toHaveLength(2)); + await act(async () => calls[1].resolve([opt("Alpha")])); + await user.keyboard("{Enter}"); + expect(onValueChange).toHaveBeenLastCalledWith("alpha", opt("Alpha")); + await user.keyboard("z"); + await waitFor(() => expect(calls).toHaveLength(3)); + await act(async () => calls[2].resolve([opt("Zeta")])); + await user.keyboard("{Escape}"); + view.rerender( + , + ); + expect(input().value).toBe("Beta"); +}); + +it("shows a preset selectedOption before any load", () => { + const { load } = deferredLoader(); + renderV1( + , + ); + expect(input().value).toBe("Saved"); + expect(load).not.toHaveBeenCalled(); +}); + +it("names the value from the options, then from a seen option, then from selectedOption", async () => { + const { load, calls } = deferredLoader(); + const { user } = renderV1( + , + ); + expect(input().value).toBe("Saved"); + await user.tab(); + await user.keyboard("{ArrowDown}"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([{ value: "x", label: "Fresh" }])); + await user.keyboard("{Escape}"); + expect(input().value).toBe("Fresh"); + await user.keyboard("z"); + await waitFor(() => expect(calls).toHaveLength(2)); + await act(async () => calls[1].resolve([])); + await user.keyboard("{Escape}"); + // No longer listed: the seen option outranks the preset. + expect(input().value).toBe("Fresh"); +}); + +it("renders a window of a 1,000-option list and filters it to one", async () => { + stubLayout(); + const options = Array.from({ length: 1000 }, (_, i) => ({ + value: `v${i}`, + label: `Option ${i}`, + })); + const started = performance.now(); + const { user } = setup({ options }); + await user.click(input()); + await user.keyboard("{ArrowDown}"); + const rows = screen.getAllByRole("option"); + expect(rows.length).toBeGreaterThan(0); + expect(rows.length).toBeLessThan(60); + await user.type(input(), "Option 999"); + expect(optionNames()).toEqual(["Option 999"]); + expect(performance.now() - started).toBeLessThan(1000); +}); + +it("has no axe violations closed and open", async () => { + const { user } = setup({ defaultValue: "claude" }); + await expect(document.body).toHaveNoA11yViolations(); + await user.click(screen.getByRole("button", { name: "Show options" })); + await expect(document.body).toHaveNoA11yViolations(); +}); diff --git a/frontend/src/v1/components/ui/select/Combobox.tsx b/frontend/src/v1/components/ui/select/Combobox.tsx new file mode 100644 index 0000000000..7e1a67b9ad --- /dev/null +++ b/frontend/src/v1/components/ui/select/Combobox.tsx @@ -0,0 +1,190 @@ +import { cn } from "@v1/lib/cn"; +import { ACTION_ICONS } from "@v1/lib/icons"; +import { Popover as PopoverPrimitive } from "radix-ui"; +import { type JSX, type KeyboardEvent, useRef, useState } from "react"; +import { OPTION_LEADING_CLASS } from "./OptionRow"; +import type { SelectOption } from "./options"; +import { + ChevronButton, + closeOnLeave, + fieldInputProps, + ICON_BUTTON, + type ListFieldProps, + ListPopover, + toggleList, + useLoadedOptions, +} from "./shared"; +import { TRIGGER_FRAME } from "./trigger"; + +const ClearIcon = ACTION_ICONS.close; +const NO_OPTIONS: readonly SelectOption[] = []; + +export type ComboboxProps = ListFieldProps & + Readonly<{ + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null, option: SelectOption | null) => void; + /** The option for `value` when it is not in `options` yet, e.g. a saved choice before the first load. */ + selectedOption?: SelectOption | null; + }>; + +/** + * A single choice from a long or server-loaded list: typing filters (or, with + * `loadOptions`, queries the server) and opens the list; focus stays on the + * input, which points at the active option with `aria-activedescendant`. + * A click on the input opens the list; focus alone does not. While the list + * is closed the input shows the selected label, selected on focus and on + * click so typing replaces it; Escape and leaving the field drop the typed + * text and show it again. + */ +export function Combobox(props: ComboboxProps): JSX.Element { + const { + options = NO_OPTIONS, + loadOptions, + value: valueProp, + defaultValue = null, + onValueChange, + selectedOption, + placeholder, + disabled, + name, + className, + } = props; + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const value = valueProp === undefined ? uncontrolled : valueProp; + // Typing since the list last closed: the input shows the query, not the label. + const [editing, setEditing] = useState(false); + + const frameRef = useRef(null); + const inputRef = useRef(null); + const contentRef = useRef(null); + + const { listbox, loading, error, lookup } = useLoadedOptions({ + loadOptions, + staticOptions: options, + extraSelected: selectedOption ? [selectedOption] : undefined, + selected: value === null ? [] : [value], + onSelect: select, + }); + const selected = value === null ? undefined : lookup(value); + + /** Closes the list and drops the typed text, so the label shows again. */ + function close() { + listbox.setOpen(false); + listbox.setQuery(""); + setEditing(false); + } + + function select(option: SelectOption) { + setUncontrolled(option.value); + onValueChange?.(option.value, option); + close(); + } + + function clear() { + setUncontrolled(null); + onValueChange?.(null, null); + close(); + inputRef.current?.focus(); + } + + function onKeyDown(event: KeyboardEvent) { + listbox.onKeyDown(event); + // Tab may move to the clear button, still inside the field. + if (event.key === "Escape" || event.key === "Tab") { + close(); + } + } + + const onBlur = closeOnLeave(frameRef, contentRef, close); + + /** The shown label is selected, so the first keystroke replaces it. */ + function selectLabel(event: { currentTarget: HTMLInputElement }) { + if (!editing) { + event.currentTarget.select(); + } + } + + return ( + (next ? listbox.setOpen(true) : close())} + > + +
+ {selected?.leading && !editing ? ( + + {selected.leading} + + ) : null} + { + setEditing(true); + listbox.setQuery(event.target.value); + }} + onKeyDown={onKeyDown} + onFocus={selectLabel} + onClick={(event) => { + selectLabel(event); + if (!listbox.open) { + listbox.setOpen(true); + } + }} + onBlur={onBlur} + className="ui:min-w-0 ui:flex-1 ui:border-0 ui:bg-transparent ui:p-0 ui:text-body ui:text-foreground ui:outline-none ui:placeholder:text-muted-foreground" + /> + {value !== null && !disabled ? ( + + ) : null} + toggleList(listbox, close, inputRef.current)} + /> + {/* A disabled control does not submit, so neither does this. */} + {name && !disabled ? ( + + ) : null} +
+
+ +
+ ); +} diff --git a/frontend/src/v1/components/ui/select/Listbox.test.tsx b/frontend/src/v1/components/ui/select/Listbox.test.tsx new file mode 100644 index 0000000000..2c71c5fc4a --- /dev/null +++ b/frontend/src/v1/components/ui/select/Listbox.test.tsx @@ -0,0 +1,269 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { renderV1, stubLayout } from "@v1/test"; +import type { JSX } from "react"; +import { expect, it, vi } from "vitest"; +import { Listbox, VIRTUALIZE_ABOVE } from "./Listbox"; +import type { SelectOption } from "./options"; +import { useListbox } from "./useListbox"; + +const FRUIT: readonly SelectOption[] = [ + { value: "apple", label: "Apple" }, + { value: "banana", label: "Banana", description: "Yellow" }, + { value: "cherry", label: "Cherry" }, +]; + +const many = (count: number): readonly SelectOption[] => + Array.from({ length: count }, (_, i) => ({ + value: `v${i}`, + label: `Option ${i}`, + })); + +type HarnessProps = Readonly<{ + options?: readonly SelectOption[]; + selected?: readonly string[]; + onSelect?: (option: SelectOption) => void; + loading?: boolean; + emptyText?: string; + error?: boolean; + multiselectable?: boolean; + "aria-label"?: string; + "aria-labelledby"?: string; +}>; + +/** An input wired to useListbox, with the list always shown. */ +function Harness({ + options = FRUIT, + selected = [], + onSelect = () => undefined, + loading, + emptyText, + error, + multiselectable, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, +}: HarnessProps): JSX.Element { + const listbox = useListbox({ options, selected, onSelect, id: "fruit" }); + return ( +
+ listbox.setQuery(event.target.value)} + onKeyDown={listbox.onKeyDown} + /> + +
+ ); +} + +it("renders each option with its optionId inside the controlled listbox", () => { + renderV1(); + const list = screen.getByRole("listbox"); + expect(list.id).toBe("fruit-listbox"); + expect(screen.getByRole("combobox").getAttribute("aria-controls")).toBe( + list.id, + ); + const rows = screen.getAllByRole("option"); + expect(rows.map((row) => row.id)).toEqual([ + "fruit-option-0", + "fruit-option-1", + "fruit-option-2", + ]); + expect(rows.map((row) => row.getAttribute("aria-selected"))).toEqual([ + "false", + "true", + "false", + ]); + expect(list.hasAttribute("aria-multiselectable")).toBe(false); + expect(list.hasAttribute("aria-busy")).toBe(false); +}); + +it("sits in one scroll element that holds the listbox", () => { + renderV1(); + const scroll = document.querySelector('[data-slot="listbox-scroll"]'); + expect(scroll?.className).toContain("ui:overflow-auto"); + expect(scroll?.className).toContain("ui:flex-1"); + expect(scroll?.className).toContain("ui:min-h-0"); + expect(scroll?.firstElementChild).toBe(screen.getByRole("listbox")); +}); + +it("names the listbox, preferring aria-labelledby to aria-label", () => { + const { unmount } = renderV1(); + expect(screen.getByRole("listbox", { name: "Fruit" })).toBeTruthy(); + unmount(); + renderV1( + <> + Fruit picker + + , + ); + const list = screen.getByRole("listbox", { name: "Fruit picker" }); + expect(list.hasAttribute("aria-label")).toBe(false); +}); + +it("marks the listbox multiselectable when asked", () => { + renderV1(); + expect(screen.getByRole("listbox").getAttribute("aria-multiselectable")).toBe( + "true", + ); +}); + +it("selects a clicked option and activates a hovered one", async () => { + const onSelect = vi.fn(); + const { user } = renderV1(); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.keyboard("{ArrowDown}"); + fireEvent.mouseMove(screen.getByRole("option", { name: "Cherry" })); + expect(input.getAttribute("aria-activedescendant")).toBe("fruit-option-2"); + expect( + screen.getByRole("option", { name: "Cherry" }).hasAttribute("data-active"), + ).toBe(true); + await user.click(screen.getByRole("option", { name: "Cherry" })); + expect(onSelect).toHaveBeenCalledWith(FRUIT[2]); +}); + +it("shows a loading row outside the listbox and marks the listbox busy", () => { + renderV1(); + const list = screen.getByRole("listbox"); + expect(list.getAttribute("aria-busy")).toBe("true"); + const status = screen.getByRole("status"); + expect(status.tagName).toBe("OUTPUT"); + expect(status.textContent).toBe("Loading…"); + expect(status.querySelector('[data-slot="spinner"]')).not.toBeNull(); + expect(list.contains(status)).toBe(false); + expect(screen.queryByText("No options")).toBeNull(); +}); + +it("says No options for an empty list and No matches for an empty filter", async () => { + const { unmount } = renderV1(); + const status = screen.getByRole("status"); + expect(status.textContent).toBe("No options"); + expect(screen.getByRole("listbox").contains(status)).toBe(false); + unmount(); + const second = renderV1(); + expect(screen.queryByRole("status")).toBeNull(); + await second.user.type(screen.getByRole("combobox"), "zzz"); + expect(screen.getByRole("status").textContent).toBe("No matches"); +}); + +it("ignores Enter while an IME composition is in progress", async () => { + const onSelect = vi.fn(); + const { user } = renderV1(); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.keyboard("{ArrowDown}"); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + expect(onSelect).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onSelect).toHaveBeenCalledWith(FRUIT[0]); +}); + +it("shows an error row beside the listbox in place of the empty row", () => { + const { rerender } = renderV1(); + const status = screen.getByRole("status"); + expect(status.tagName).toBe("OUTPUT"); + expect(status.textContent).toBe("Could not load options"); + expect(screen.getByRole("listbox").contains(status)).toBe(false); + expect(screen.queryByText("No options")).toBeNull(); + rerender(); + expect(screen.getByRole("status").textContent).toBe("Loading…"); +}); + +it("uses the caller's empty text", () => { + renderV1(); + expect(screen.getByRole("status").textContent).toBe("No models yet"); +}); + +it("scrolls the active row into view only while open", async () => { + const scrollIntoView = vi.spyOn(Element.prototype, "scrollIntoView"); + const { user } = renderV1(); + await user.click(screen.getByRole("combobox")); + expect(scrollIntoView).not.toHaveBeenCalled(); + await user.keyboard("{ArrowDown}{End}"); + const target = scrollIntoView.mock.contexts.at(-1) as Element; + expect(target.id).toBe("fruit-option-2"); + expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" }); +}); + +it("renders every row of a list at the threshold", () => { + renderV1(); + expect(screen.getAllByRole("option")).toHaveLength(VIRTUALIZE_ABOVE); + expect(screen.getAllByRole("option")[0].hasAttribute("aria-setsize")).toBe( + false, + ); + expect(screen.getByRole("listbox").className).not.toContain("ui:relative"); +}); + +it("renders only the rows in view of a 1,000-option list", () => { + stubLayout(); + const options = many(1000); + const started = performance.now(); + renderV1(); + const elapsed = performance.now() - started; + const rows = screen.getAllByRole("option"); + expect(rows.length).toBeGreaterThan(0); + expect(rows.length).toBeLessThan(60); + expect(elapsed).toBeLessThan(1000); + expect(rows[0].id).toBe("fruit-option-0"); + const list = screen.getByRole("listbox"); + expect(list.className).toContain("ui:relative"); + expect(list.className).toContain("ui:h-(--listbox-height)"); + expect(list.style.getPropertyValue("--listbox-height")).toBe("33998px"); + const wrapper = rows[1].parentElement as HTMLElement; + expect(wrapper.className).toContain("ui:translate-y-(--row-y)"); + expect(wrapper.style.getPropertyValue("--row-y")).toBe("34px"); + // Only a window is in the DOM, so each row states its place in the list. + expect(rows[1].getAttribute("aria-setsize")).toBe("1000"); + expect(rows[1].getAttribute("aria-posinset")).toBe("2"); + expect(document.querySelector('[data-slot="listbox-scroll"]')).not.toBeNull(); +}); + +it("sizes a virtual row with a description taller", () => { + stubLayout(); + const options = many(200).map((option, i) => + i === 0 ? { ...option, description: "First" } : option, + ); + renderV1(); + const rows = screen.getAllByRole("option"); + expect( + (rows[1].parentElement as HTMLElement).style.getPropertyValue("--row-y"), + ).toBe("50px"); +}); + +it("brings the active row of a virtual list into the rendered window", async () => { + stubLayout(); + const { user } = renderV1(); + expect(document.getElementById("fruit-option-999")).toBeNull(); + await user.click(screen.getByRole("combobox")); + await user.keyboard("{ArrowDown}{End}"); + const scroll = document.querySelector( + '[data-slot="listbox-scroll"]', + ) as HTMLElement; + expect(scroll.scrollTop).toBeGreaterThan(0); + // happy-dom moves scrollTop without the scroll event a browser fires. + fireEvent.scroll(scroll); + const last = document.getElementById("fruit-option-999"); + expect(last).not.toBeNull(); + expect(last?.hasAttribute("data-active")).toBe(true); +}); + +it("has no axe violations with options, while loading, and when empty", async () => { + const { unmount } = renderV1(); + await expect(document.body).toHaveNoA11yViolations(); + unmount(); + const loading = renderV1(); + await expect(document.body).toHaveNoA11yViolations(); + loading.unmount(); + renderV1(); + await expect(document.body).toHaveNoA11yViolations(); +}); diff --git a/frontend/src/v1/components/ui/select/Listbox.tsx b/frontend/src/v1/components/ui/select/Listbox.tsx new file mode 100644 index 0000000000..18c89caf04 --- /dev/null +++ b/frontend/src/v1/components/ui/select/Listbox.tsx @@ -0,0 +1,165 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Spinner } from "@v1/components/ui/feedback"; +import { cn } from "@v1/lib/cn"; +import { type JSX, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { OptionRow } from "./OptionRow"; +import type { Listbox as ListboxState } from "./useListbox"; + +/** Above this many visible options, only the rows in view are rendered. */ +export const VIRTUALIZE_ABOVE = 100; + +// Row heights (`ui:h-8`, `ui:h-12`) and the gap between rows (`ui:gap-0.5`). +const ROW_HEIGHT = 32; +const ROW_HEIGHT_WITH_DESCRIPTION = 48; +const ROW_GAP = 2; +// What the virtualiser assumes before it can measure (and in tests): the +// list surface's cap, `ui:max-h-80`. +const INITIAL_VIEWPORT = { width: 0, height: 320 }; + +const STATUS_ROW = "ui:flex ui:h-8 ui:items-center ui:px-2"; + +export type ListboxProps = Readonly<{ + listbox: ListboxState; + loading?: boolean; + emptyText?: string; + /** The options failed to load: a "Could not load options" row replaces the empty row. */ + error?: boolean; + multiselectable?: boolean; + /** The field's name for the listbox; `aria-labelledby` wins when both are given. */ + "aria-label"?: string; + "aria-labelledby"?: string; + className?: string; +}>; + +/** + * The option list of a Combobox or MultiSelect, for inside the caller's + * `Popover.Content` (a flex column with `ui:overflow-hidden`): this is its one + * scroll element. Past `VIRTUALIZE_ABOVE` options only the rows in view are + * rendered. The loading, error and empty rows sit beside the listbox, since a + * listbox may hold only options. The listbox takes the field's name. A + * virtual list states each rendered row's place (`aria-setsize`, + * `aria-posinset`), since only a window of rows is in the DOM. + */ +export function Listbox({ + listbox, + loading = false, + emptyText, + error = false, + multiselectable, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + className, +}: ListboxProps): JSX.Element { + const { visible, activeIndex, optionId, setActiveIndex } = listbox; + const scrollRef = useRef(null); + const selected = useMemo(() => new Set(listbox.selected), [listbox.selected]); + + const virtual = visible.length > VIRTUALIZE_ABOVE; + const virtualizer = useVirtualizer({ + count: visible.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + visible[index]?.description ? ROW_HEIGHT_WITH_DESCRIPTION : ROW_HEIGHT, + gap: ROW_GAP, + overscan: 8, + enabled: virtual, + initialRect: INITIAL_VIEWPORT, + }); + // Rows are placed from estimateSize, which the virtualiser caches: a new + // list must re-measure, or rows keep the old heights. + // biome-ignore lint/correctness/useExhaustiveDependencies: `visible` is the trigger; estimateSize reads it + useLayoutEffect(() => { + virtualizer.measure(); + }, [visible, virtualizer]); + + const { open } = listbox; + useEffect(() => { + if (!open || activeIndex < 0) { + return; + } + if (virtual) { + virtualizer.scrollToIndex(activeIndex); + } else { + scrollRef.current?.ownerDocument + .getElementById(optionId(activeIndex)) + ?.scrollIntoView({ block: "nearest" }); + } + }, [open, activeIndex, virtual, virtualizer, optionId]); + + const renderRow = (index: number) => { + const option = visible[index]; + return ( + listbox.onSelect(option)} + onActivate={() => setActiveIndex(index)} + aria-setsize={virtual ? visible.length : undefined} + aria-posinset={virtual ? index + 1 : undefined} + /> + ); + }; + + return ( +
+ {loading ? ( + + + Loading… + + ) : null} +
+ {virtual + ? virtualizer.getVirtualItems().map((item) => ( +
+ {renderRow(item.index)} +
+ )) + : visible.map((_, index) => renderRow(index))} +
+ {!loading && error ? ( + + Could not load options + + ) : null} + {!loading && !error && visible.length === 0 ? ( + + {emptyText ?? (listbox.query ? "No matches" : "No options")} + + ) : null} +
+ ); +} diff --git a/frontend/src/v1/components/ui/select/MultiSelect.test.tsx b/frontend/src/v1/components/ui/select/MultiSelect.test.tsx new file mode 100644 index 0000000000..9c33d688d1 --- /dev/null +++ b/frontend/src/v1/components/ui/select/MultiSelect.test.tsx @@ -0,0 +1,396 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import { expectNoA11yViolations, renderV1 } from "@v1/test"; +import { expect, it, vi } from "vitest"; +import { MultiSelect, type MultiSelectProps } from "./MultiSelect"; +import type { SelectOption } from "./options"; +import type { LoadOptions } from "./useAsyncOptions"; + +const TAGS: readonly SelectOption[] = [ + { value: "invoice", label: "Invoice" }, + { value: "receipt", label: "Receipt", description: "Paid" }, + { value: "archived", label: "Archived", disabled: true }, + { value: "contract", label: "Contract", keywords: ["agreement"] }, +]; + +function input(): HTMLInputElement { + return screen.getByRole("combobox", { name: "Tags" }) as HTMLInputElement; +} + +const chipNames = () => + [...document.querySelectorAll('[data-slot="chip"]')].map( + (chip) => chip.textContent, + ); + +const option = (name: string | RegExp) => screen.getByRole("option", { name }); + +function setup(props: Partial = {}) { + const onValueChange = vi.fn(); + const view = renderV1( + <> + + + , + ); + return { ...view, onValueChange }; +} + +it("shows a chip per value, in order, named by label with a remove button", () => { + setup({ defaultValue: ["receipt", "invoice", "gone"] }); + expect(chipNames()).toEqual(["Receipt", "Invoice", "gone"]); + expect(screen.getByRole("button", { name: "Remove Receipt" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remove gone" })).toBeTruthy(); + expect(input().nextElementSibling).toBe( + screen.getByRole("button", { name: "Show options" }), + ); +}); + +it("shows the placeholder only when nothing is selected", () => { + const { unmount } = setup(); + expect(input().placeholder).toBe("Add tags"); + unmount(); + setup({ defaultValue: ["invoice"] }); + expect(input().placeholder).toBe(""); +}); + +it("adds a chosen option as a chip and keeps the list open", async () => { + const { user, onValueChange } = setup({ defaultValue: ["invoice"] }); + await user.type(input(), "con"); + await user.click(option("Contract")); + expect(onValueChange).toHaveBeenLastCalledWith(["invoice", "contract"]); + expect(chipNames()).toEqual(["Invoice", "Contract"]); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(input().value).toBe(""); + expect(screen.getAllByRole("option")).toHaveLength(TAGS.length); + expect(document.activeElement).toBe(input()); +}); + +it("opens on a click on the input", async () => { + const { user } = setup(); + await user.click(input()); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(input().getAttribute("aria-expanded")).toBe("true"); +}); + +it("focuses the input and opens on a press on the frame's padding", async () => { + const { user } = setup({ defaultValue: ["invoice"] }); + const frame = input().closest('[data-slot="multi-select"]') as HTMLElement; + await user.click(frame); + expect(document.activeElement).toBe(input()); + expect(screen.getByRole("listbox")).toBeTruthy(); + // A chip's label is not a button either. + await user.keyboard("{Escape}"); + await user.click(screen.getByText("Invoice")); + expect(document.activeElement).toBe(input()); + expect(screen.getByRole("listbox")).toBeTruthy(); +}); + +it("leaves a press on a chip's remove button to the button", async () => { + const { user } = setup({ defaultValue: ["invoice", "receipt"] }); + await user.click(screen.getByRole("button", { name: "Remove Invoice" })); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(chipNames()).toEqual(["Receipt"]); +}); + +it("toggles the active option on Enter", async () => { + const { user, onValueChange } = setup(); + await user.tab(); + await user.keyboard("{ArrowDown}{ArrowDown}{Enter}"); + expect(onValueChange).toHaveBeenLastCalledWith(["receipt"]); + expect(screen.getByRole("listbox")).toBeTruthy(); + // The toggled option stays active, so the arrows carry on from it. + expect(input().getAttribute("aria-activedescendant")).toBe( + option(/Receipt/).id, + ); + await user.keyboard("{ArrowDown}"); + expect(input().getAttribute("aria-activedescendant")).toBe( + option("Contract").id, + ); + await user.keyboard("{Enter}"); + expect(onValueChange).toHaveBeenLastCalledWith(["receipt", "contract"]); + expect(chipNames()).toEqual(["Receipt", "Contract"]); +}); + +it("keeps a filtered option active after Enter clears the query", async () => { + const { user, onValueChange } = setup(); + await user.type(input(), "agree{Enter}"); + expect(onValueChange).toHaveBeenLastCalledWith(["contract"]); + expect(input().value).toBe(""); + expect(screen.getAllByRole("option")).toHaveLength(TAGS.length); + expect(input().getAttribute("aria-activedescendant")).toBe( + option("Contract").id, + ); +}); + +it("removes an already-selected option when chosen again, keeping the list open", async () => { + const { user, onValueChange } = setup({ + defaultValue: ["invoice", "receipt"], + }); + await user.click(screen.getByRole("button", { name: "Show options" })); + await user.click(option("Invoice")); + expect(onValueChange).toHaveBeenLastCalledWith(["receipt"]); + expect(chipNames()).toEqual(["Receipt"]); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(option("Invoice").getAttribute("aria-selected")).toBe("false"); +}); + +it("is a multiselectable listbox that marks every chosen option", async () => { + const { user } = setup({ defaultValue: ["invoice", "contract"] }); + await user.click(screen.getByRole("button", { name: "Show options" })); + expect( + screen + .getByRole("listbox", { name: "Tags" }) + .getAttribute("aria-multiselectable"), + ).toBe("true"); + const selected = screen + .getAllByRole("option") + .filter((row) => row.getAttribute("aria-selected") === "true") + .map((row) => row.textContent); + expect(selected).toEqual(["Invoice", "Contract"]); +}); + +it("removes a chip with its button and returns focus to the input", async () => { + const { user, onValueChange } = setup({ + defaultValue: ["invoice", "receipt"], + }); + await user.click(screen.getByRole("button", { name: "Remove Invoice" })); + expect(onValueChange).toHaveBeenLastCalledWith(["receipt"]); + expect(chipNames()).toEqual(["Receipt"]); + expect(document.activeElement).toBe(input()); +}); + +it("removes the last chip on Backspace in an empty input", async () => { + const { user, onValueChange } = setup({ + defaultValue: ["invoice", "receipt"], + }); + await user.click(input()); + await user.keyboard("{Backspace}"); + expect(onValueChange).toHaveBeenLastCalledWith(["invoice"]); + expect(chipNames()).toEqual(["Invoice"]); + await user.keyboard("{Backspace}{Backspace}"); + expect(chipNames()).toEqual([]); + expect(onValueChange).toHaveBeenCalledTimes(2); +}); + +it("edits the text on Backspace when the input has some, keeping the chips", async () => { + const { user, onValueChange } = setup({ defaultValue: ["invoice"] }); + await user.type(input(), "re{Backspace}"); + expect(input().value).toBe("r"); + expect(chipNames()).toEqual(["Invoice"]); + expect(onValueChange).not.toHaveBeenCalled(); +}); + +it("closes and clears the query on Escape", async () => { + const { user, onValueChange } = setup(); + await user.type(input(), "con"); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(input().value).toBe(""); + expect(onValueChange).not.toHaveBeenCalled(); +}); + +it("closes and clears the query when focus leaves", async () => { + const { user } = setup(); + await user.type(input(), "con"); + await user.click(screen.getByRole("button", { name: "Elsewhere" })); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(input().value).toBe(""); +}); + +it("closes on Tab", async () => { + const { user } = setup(); + await user.type(input(), "con"); + await user.tab(); + expect(screen.queryByRole("listbox")).toBeNull(); +}); + +it("stays open when the input or a chip's remove button is pressed", async () => { + const { user } = setup({ defaultValue: ["invoice", "receipt"] }); + await user.type(input(), "con"); + await user.click(input()); + expect(screen.getByRole("listbox")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Remove Invoice" })); + expect(screen.getByRole("listbox")).toBeTruthy(); +}); + +it("toggles the list with the chevron, keeping focus on the input", async () => { + const { user } = setup(); + const chevron = screen.getByRole("button", { name: "Show options" }); + expect(chevron.tabIndex).toBe(-1); + expect(chevron.getAttribute("aria-controls")).toBe( + input().getAttribute("aria-controls"), + ); + await user.type(input(), "con"); + // The press never takes focus from the input, even for a moment. + const blurs = vi.fn(); + input().addEventListener("blur", blurs); + await user.click(chevron); + expect(blurs).not.toHaveBeenCalled(); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(chevron.getAttribute("aria-expanded")).toBe("false"); + expect(input().value).toBe(""); + expect(document.activeElement).toBe(input()); + await user.click(chevron); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(document.activeElement).toBe(input()); + await user.click(chevron); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(document.activeElement).toBe(input()); +}); + +it("keeps the chips out of the tab order: Tab lands on the input", async () => { + const { user } = renderV1( + <> + + + , + ); + await user.click(screen.getByRole("button", { name: "Before" })); + await user.tab(); + expect(document.activeElement).toBe(input()); + expect(screen.getByRole("button", { name: "Remove Invoice" }).tabIndex).toBe( + -1, + ); + await expectNoA11yViolations(document.body); +}); + +it("collapses duplicates in a controlled value", () => { + setup({ value: ["invoice", "invoice", "receipt"] }); + expect(chipNames()).toEqual(["Invoice", "Receipt"]); +}); + +it("follows a controlled value", async () => { + const { user, onValueChange, rerender } = setup({ value: ["invoice"] }); + await user.click(screen.getByRole("button", { name: "Show options" })); + await user.click(option("Contract")); + expect(onValueChange).toHaveBeenLastCalledWith(["invoice", "contract"]); + expect(chipNames()).toEqual(["Invoice"]); + rerender( + , + ); + expect(chipNames()).toEqual(["Receipt"]); +}); + +it("submits one hidden input per value when named", async () => { + const { user } = setup({ name: "tags", defaultValue: ["invoice"] }); + const values = () => + [ + ...document.querySelectorAll( + 'input[type="hidden"][name="tags"]', + ), + ].map((hidden) => hidden.value); + expect(values()).toEqual(["invoice"]); + await user.click(screen.getByRole("button", { name: "Show options" })); + await user.click(option("Contract")); + expect(values()).toEqual(["invoice", "contract"]); +}); + +it("disables the input and the chips, and submits nothing, when disabled", () => { + setup({ name: "tags", defaultValue: ["invoice"], disabled: true }); + const frame = input().closest('[data-slot="multi-select"]') as HTMLElement; + expect(input().disabled).toBe(true); + expect( + ( + screen.getByRole("button", { + name: "Remove Invoice", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(frame.hasAttribute("data-disabled")).toBe(true); + expect(document.querySelector('input[type="hidden"]')).toBeNull(); +}); + +it("passes id, invalid and aria-describedby to the input", () => { + setup({ id: "tags-input", invalid: true, "aria-describedby": "tags-help" }); + const frame = input().closest('[data-slot="multi-select"]') as HTMLElement; + expect(input().id).toBe("tags-input"); + expect(input().getAttribute("aria-invalid")).toBe("true"); + expect(input().getAttribute("aria-describedby")).toBe("tags-help"); + expect(frame.hasAttribute("data-invalid")).toBe(true); + expect(frame.className).toContain("ui:h-auto"); + expect(frame.className).not.toContain("ui:h-8"); +}); + +it("names preset chips from selectedOptions before any load", () => { + const load = vi.fn(() => new Promise(() => undefined)); + renderV1( + , + ); + expect(chipNames()).toEqual(["Saved X", "y"]); + expect(load).not.toHaveBeenCalled(); +}); + +it("names a chip from the options, then from a seen option, then from selectedOptions", async () => { + const calls: { resolve: (options: readonly SelectOption[]) => void }[] = []; + const load = vi.fn( + () => + new Promise((resolve) => { + calls.push({ resolve }); + }), + ); + const { user } = renderV1( + , + ); + expect(chipNames()).toEqual(["Saved X"]); + await user.tab(); + await user.keyboard("{ArrowDown}"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([{ value: "x", label: "Fresh X" }])); + expect(chipNames()).toEqual(["Fresh X"]); + await user.keyboard("z"); + await waitFor(() => expect(calls).toHaveLength(2)); + await act(async () => calls[1].resolve([])); + expect(chipNames()).toEqual(["Fresh X"]); +}); + +it("keeps a loaded chip's label after the query changes", async () => { + const calls: { + query: string; + resolve: (options: readonly SelectOption[]) => void; + }[] = []; + const load = vi.fn( + (query) => + new Promise((resolve) => { + calls.push({ query, resolve }); + }), + ); + const { user } = renderV1( + , + ); + await user.tab(); + await user.keyboard("a"); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].resolve([{ value: "a", label: "Alpha" }])); + await user.keyboard("{Enter}"); + expect(chipNames()).toEqual(["Alpha"]); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1].query).toBe(""); + await act(async () => calls[1].resolve([{ value: "b", label: "Beta" }])); + expect(chipNames()).toEqual(["Alpha"]); +}); + +it("has no axe violations closed and open", async () => { + const { user } = setup({ defaultValue: ["invoice"] }); + await expect(document.body).toHaveNoA11yViolations(); + await user.click(screen.getByRole("button", { name: "Show options" })); + await expect(document.body).toHaveNoA11yViolations(); +}); diff --git a/frontend/src/v1/components/ui/select/MultiSelect.tsx b/frontend/src/v1/components/ui/select/MultiSelect.tsx new file mode 100644 index 0000000000..baf4f587cf --- /dev/null +++ b/frontend/src/v1/components/ui/select/MultiSelect.tsx @@ -0,0 +1,223 @@ +import { cn } from "@v1/lib/cn"; +import { Popover as PopoverPrimitive } from "radix-ui"; +import { + type JSX, + type KeyboardEvent, + type MouseEvent, + useMemo, + useRef, + useState, +} from "react"; +import { Chip } from "./Chip"; +import type { SelectOption } from "./options"; +import { + ChevronButton, + closeOnLeave, + fieldInputProps, + type ListFieldProps, + ListPopover, + toggleList, + useLoadedOptions, +} from "./shared"; +import { TRIGGER_FRAME } from "./trigger"; + +const NO_OPTIONS: readonly SelectOption[] = []; +const NO_VALUES: readonly string[] = []; + +export type MultiSelectProps = ListFieldProps & + Readonly<{ + /** The selected values, in chip order; duplicates are collapsed. */ + value?: readonly string[]; + defaultValue?: readonly string[]; + onValueChange?: (values: readonly string[]) => void; + /** The options for values not in `options` yet, e.g. saved choices before the first load. */ + selectedOptions?: readonly SelectOption[]; + }>; + +/** + * Several choices from a long or server-loaded list, shown as chips before + * the input. A click on the input or the frame opens the list; focus alone + * does not. Typing filters (or, with `loadOptions`, queries the server); + * Enter or a click toggles an option and keeps the list open with the query + * cleared; Backspace in an empty input removes the last chip. + */ +export function MultiSelect(props: MultiSelectProps): JSX.Element { + const { + options = NO_OPTIONS, + loadOptions, + value: valueProp, + defaultValue = NO_VALUES, + onValueChange, + selectedOptions, + disabled, + name, + } = props; + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const raw = valueProp ?? uncontrolled; + const value = useMemo(() => [...new Set(raw)], [raw]); + + const frameRef = useRef(null); + const inputRef = useRef(null); + const contentRef = useRef(null); + + // A chip keeps its label when the list (or a server response) no longer + // holds its option: the lookup remembers every option seen. + const { listbox, loading, error, lookup } = useLoadedOptions({ + loadOptions, + staticOptions: options, + extraSelected: selectedOptions, + selected: value, + onSelect: toggle, + }); + + function change(next: readonly string[]) { + setUncontrolled(next); + onValueChange?.(next); + } + + function remove(item: string) { + change(value.filter((other) => other !== item)); + } + + /** + * Adds or removes the option; the list stays open for the next one, with + * the query cleared and the toggled option still active. + */ + function toggle(option: SelectOption) { + change( + value.includes(option.value) + ? value.filter((other) => other !== option.value) + : [...value, option.value], + ); + const index = listbox.visible.findIndex( + (other) => other.value === option.value, + ); + listbox.setQuery(""); + // setQuery lands on the first option; the active row is tracked by value, + // so this keeps the toggled option active in the unfiltered list. + listbox.setActiveIndex(index); + } + + /** Closes the list and drops the typed text. */ + function close() { + listbox.setOpen(false); + listbox.setQuery(""); + } + + function onKeyDown(event: KeyboardEvent) { + // With no text to delete, Backspace takes the last chip instead. + if (event.key === "Backspace" && listbox.query === "" && value.length > 0) { + event.preventDefault(); + remove(value[value.length - 1]); + return; + } + listbox.onKeyDown(event); + if (event.key === "Escape" || event.key === "Tab") { + close(); + } + } + + const onBlur = closeOnLeave(frameRef, contentRef, close); + + /** + * A press on the frame's padding or a chip's label focuses the input and + * opens the list, as a press on the input does; buttons keep their own. + */ + function onFrameMouseDown(event: MouseEvent) { + const target = event.target; + if ( + disabled || + target === inputRef.current || + (target instanceof Element && target.closest("button")) + ) { + return; + } + event.preventDefault(); + inputRef.current?.focus(); + if (!listbox.open) { + listbox.setOpen(true); + } + } + + return ( + (next ? listbox.setOpen(true) : close())} + > + + {/* biome-ignore lint/a11y/noStaticElementInteractions: the mousedown only forwards a press on the padding to the input, which the keyboard reaches directly. */} + {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: as above; the frame is not a control of its own. */} +
+ {value.map((item) => ( + { + remove(item); + inputRef.current?.focus(); + }} + /> + ))} + listbox.setQuery(event.target.value)} + onKeyDown={onKeyDown} + onClick={() => { + if (!listbox.open) { + listbox.setOpen(true); + } + }} + onBlur={onBlur} + className="ui:min-w-16 ui:flex-1 ui:border-0 ui:bg-transparent ui:p-0 ui:text-body ui:text-foreground ui:outline-none ui:placeholder:text-muted-foreground" + /> + toggleList(listbox, close, inputRef.current)} + /> + {/* A disabled control does not submit, so neither do these. */} + {name && !disabled + ? value.map((item) => ( + + )) + : null} +
+
+ +
+ ); +} diff --git a/frontend/src/v1/components/ui/select/OptionRow.test.tsx b/frontend/src/v1/components/ui/select/OptionRow.test.tsx new file mode 100644 index 0000000000..47886a1ce3 --- /dev/null +++ b/frontend/src/v1/components/ui/select/OptionRow.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { renderV1 } from "@v1/test"; +import { Star } from "lucide-react"; +import { expect, it, vi } from "vitest"; +import { OptionRow } from "./OptionRow"; +import type { SelectOption } from "./options"; + +const GPT: SelectOption = { value: "gpt-4o", label: "GPT-4o" }; + +it("renders an option named by its label, without a description", () => { + renderV1(); + const row = screen.getByRole("option", { name: "GPT-4o" }); + expect(row.id).toBe("o1"); + expect(row.getAttribute("data-slot")).toBe("option"); + expect(row.getAttribute("aria-selected")).toBe("false"); + expect(row.textContent).toBe("GPT-4o"); + expect(row.className).toContain("ui:h-8"); + expect(row.className).not.toContain("ui:h-12"); +}); + +it("renders the description and the taller row when one is given", () => { + renderV1( + , + ); + const row = screen.getByRole("option", { name: /GPT-4o/ }); + expect(screen.getByText("OpenAI omni model")).toBeTruthy(); + expect(row.className).toContain("ui:h-12"); + expect(row.className).not.toContain("ui:h-8"); +}); + +it("draws the check only on the selected option", () => { + const { rerender } = renderV1( + , + ); + expect(document.querySelector("svg.lucide-check")).toBeNull(); + rerender(); + const row = screen.getByRole("option", { name: "GPT-4o" }); + expect(row.getAttribute("aria-selected")).toBe("true"); + expect(row.querySelector("svg.lucide-check")).not.toBeNull(); +}); + +it("renders the leading slot only when given", () => { + const { rerender } = renderV1( + , + ); + expect(document.querySelector('[data-slot="option-leading"]')).toBeNull(); + rerender( + }} + selected={false} + active={false} + />, + ); + const leading = document.querySelector('[data-slot="option-leading"]'); + expect(leading?.querySelector("svg.lucide-star")).not.toBeNull(); +}); + +it("marks the active row with data-active", () => { + const { rerender } = renderV1( + , + ); + const row = screen.getByRole("option"); + expect(row.hasAttribute("data-active")).toBe(false); + rerender(); + expect(row.getAttribute("data-active")).toBe(""); +}); + +it("sets aria-disabled only on a disabled option", () => { + const { rerender } = renderV1( + , + ); + const row = screen.getByRole("option"); + expect(row.hasAttribute("aria-disabled")).toBe(false); + expect(row.className).not.toContain("ui:pointer-events-none"); + expect(row.className).not.toContain("ui:opacity-50"); + rerender( + , + ); + expect(row.getAttribute("aria-disabled")).toBe("true"); + expect(row.className).toContain("ui:pointer-events-none"); + expect(row.className).toContain("ui:opacity-50"); +}); + +it("ignores synthetic clicks and mouse moves on a disabled option", () => { + const onSelect = vi.fn(); + const onActivate = vi.fn(); + renderV1( + , + ); + const row = screen.getByRole("option"); + fireEvent.click(row); + fireEvent.mouseMove(row); + expect(onSelect).not.toHaveBeenCalled(); + expect(onActivate).not.toHaveBeenCalled(); +}); + +it("selects an enabled option on a synthetic click", () => { + const onSelect = vi.fn(); + renderV1( + , + ); + fireEvent.click(screen.getByRole("option")); + expect(onSelect).toHaveBeenCalledTimes(1); +}); + +it("calls onSelect on click and onActivate on mouse move", async () => { + const onSelect = vi.fn(); + const onActivate = vi.fn(); + const { user } = renderV1( + , + ); + const row = screen.getByRole("option"); + fireEvent.mouseMove(row); + expect(onActivate).toHaveBeenCalledTimes(1); + await user.click(row); + expect(onSelect).toHaveBeenCalledTimes(1); +}); + +it("prevents the mousedown default so a combobox input keeps focus", () => { + renderV1(); + const event = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }); + screen.getByRole("option").dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); +}); diff --git a/frontend/src/v1/components/ui/select/OptionRow.tsx b/frontend/src/v1/components/ui/select/OptionRow.tsx new file mode 100644 index 0000000000..9c0af001b0 --- /dev/null +++ b/frontend/src/v1/components/ui/select/OptionRow.tsx @@ -0,0 +1,101 @@ +import { cn } from "@v1/lib/cn"; +import { ACTION_ICONS } from "@v1/lib/icons"; +import type { JSX } from "react"; +import type { SelectOption } from "./options"; + +const CheckIcon = ACTION_ICONS.confirm; + +/* + * The row's classes, shared with the Radix `Select.Item`, which renders its + * own element. The row is `ui:h-8`, or `ui:h-12` with a description. + */ + +/** The row frame; the active or highlighted row takes the accent. */ +export const OPTION_ROW_CLASS = + "ui:flex ui:w-full ui:cursor-pointer ui:items-center ui:gap-2.5 ui:rounded-md ui:px-2 ui:text-left ui:outline-none ui:select-none ui:data-active:bg-accent ui:data-highlighted:bg-accent"; + +/** The option's label. */ +export const OPTION_LABEL_CLASS = + "ui:truncate ui:text-body-small ui:font-medium ui:text-foreground"; + +/** The line under the label. */ +export const OPTION_DESCRIPTION_CLASS = + "ui:truncate ui:text-caption ui:text-muted-foreground"; + +/** The 16px slot for a lucide icon or a provider logo before the label. */ +export const OPTION_LEADING_CLASS = + "ui:flex ui:size-4 ui:shrink-0 ui:items-center ui:justify-center ui:text-muted-foreground ui:[&>svg]:size-4"; + +/** The check on the selected option. */ +export const OPTION_CHECK_CLASS = + "ui:ml-auto ui:size-4 ui:shrink-0 ui:text-primary"; + +export type OptionRowProps = Readonly<{ + option: SelectOption; + selected: boolean; + /** The keyboard-active row, the target of `aria-activedescendant`. */ + active: boolean; + id: string; + onSelect?: () => void; + onActivate?: () => void; + /** A virtual list's size and this row's 1-based place in it. */ + "aria-setsize"?: number; + "aria-posinset"?: number; + className?: string; +}>; + +/** + * One `role="option"` row in a combobox listbox. Focus stays on the + * combobox input, so the row is not focusable and its mousedown is + * default-prevented to keep the input from losing focus. + */ +export function OptionRow({ + option, + selected, + active, + id, + onSelect, + onActivate, + "aria-setsize": setSize, + "aria-posinset": posInSet, + className, +}: OptionRowProps): JSX.Element { + return ( + // biome-ignore lint/a11y/useFocusableInteractive: focus stays on the combobox input, which points here with aria-activedescendant. + // biome-ignore lint/a11y/useKeyWithClickEvents: the combobox input handles the keyboard for every row. +
event.preventDefault()} + onClick={option.disabled ? undefined : onSelect} + onMouseMove={option.disabled ? undefined : onActivate} + className={cn( + OPTION_ROW_CLASS, + option.description ? "ui:h-12" : "ui:h-8", + option.disabled && "ui:pointer-events-none ui:opacity-50", + className, + )} + > + {option.leading ? ( + + {option.leading} + + ) : null} + + {option.label} + {option.description ? ( + {option.description} + ) : null} + + {selected ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/v1/components/ui/select/README.md b/frontend/src/v1/components/ui/select/README.md new file mode 100644 index 0000000000..95d9b72c72 --- /dev/null +++ b/frontend/src/v1/components/ui/select/README.md @@ -0,0 +1,286 @@ +# Selection inputs + +The one way v1 picks from a list: dropdowns, searchable pickers, chip inputs +and segmented switches (P1-T04, UN-4301). Import everything from +`@v1/components/ui/select`: + +```tsx +import { + Chip, + Combobox, + type LoadOptions, + MultiSelect, + SegmentedControl, + Select, + type SelectOption, +} from "@v1/components/ui/select"; +``` + +| Component | Use it for | Size | +| --- | --- | --- | +| `Select` | One choice from a short, known list | 32 tall; under about 50 options | +| `Combobox` | One choice from a long or server-loaded list, by typing | 32 tall; any length, virtualised past 100 | +| `MultiSelect` | Several choices, shown as chips | 32 tall, grows as chips wrap; any length, virtualised past 100 | +| `SegmentedControl` | Exactly one of a few always-visible options | 32 tall; 2 to 5 options | +| `Chip` | A selected value with a remove button (MultiSelect uses it) | 20 tall | + +## Rules + +- **The trigger is the field frame.** `Select`, `Combobox` and `MultiSelect` + use the P1-T03 `FieldFrame` recipe verbatim (`TRIGGER_FRAME` in + `trigger.ts`: 32 tall, radius 8), so a select and a text input sit + identically in a form. `MultiSelect` swaps the fixed height for + `min-h-8` and wraps its chips. Only `Select`'s trigger is + `cursor-pointer`: it is a button, while the Combobox and MultiSelect + frames hold a text input. +- **Pick by list size.** `Select` (Radix Select) renders every option, so it + is for lists under about 50 options. Longer lists, and anything loaded from + the server, use `Combobox` (or `MultiSelect` for several values), which + filter as you type and, past `VIRTUALIZE_ABOVE` = 100 options, render only + the rows in view (`@tanstack/react-virtual`). `Select` never virtualises and + has no `loading` prop. Past five always-visible options, a + `SegmentedControl` becomes a `Select`. +- **One option model.** `Select`, `Combobox` and `MultiSelect` take + `SelectOption` (`SegmentedControl` takes its own `SegmentedOption`, below): + + ```ts + type SelectOption = Readonly<{ + value: string; + label: string; + description?: string; // a second line, in the caption role + leading?: ReactNode; // a lucide icon or + disabled?: boolean; + keywords?: readonly string[]; // extra words the filter matches + }>; + ``` + + `keywords` feed the Combobox and MultiSelect filter (a case-insensitive + substring of the label or any keyword, e.g. a model id). Type-ahead is + Radix Select's own (it matches labels); `Combobox` and `MultiSelect` + filter instead. Logos go in `leading`: + `leading: ` + from `@v1/components/ui/BrandMark` (decorative, because the label beside it + already names it). A row is 32 tall, or 48 with a description. +- **One list surface.** Every popover list is `LIST_SURFACE`: the popover + colour, radius 10 (`rounded-lg`), `shadow-md` (board § 6, "dropdown + menus"), at least 192 wide and the trigger's width, at most 320 tall. It is + a flex column with hidden overflow and exactly **one** scroll element + inside: Radix Select's Viewport, or the `Listbox` scroll element for the + Popover lists. That scroll element is also capped at the height Radix + finds free around the trigger (`--radix-select-content-available-height` + or `--radix-popover-content-available-height`), so a short viewport scrolls + the list instead of pushing the active option off-screen. In the Popover lists the status rows ("Loading…", "No + options" or "No matches", "Could not load options") are `` + elements beside the `role="listbox"` element, never inside it, since a + listbox may hold only options. `Select`'s list is Radix's listbox + itself, so its "No options" row, shown only when the list is empty, is a + disabled `role="option"` outside Radix's collection (the keyboard never + lands on it): axe rejects a status element there, even inside a group, + and an empty listbox. +- **Combobox pattern.** `Combobox` and `MultiSelect` follow the ARIA + combobox-with-listbox pattern: focus stays on the input (`role="combobox"`, + `aria-expanded`, `aria-controls`, `aria-autocomplete="list"`), which points + at the active `role="option"` with `aria-activedescendant`; no live region. + Each option's id is tied to its value, not its index, so when typing + changes the first match the active id changes and a screen reader + announces it. The listbox takes the field's `aria-labelledby` (or + `aria-label`), and the chevron's `aria-controls` points at it. A virtual + list gives each rendered row `aria-setsize` and `aria-posinset`. + `MultiSelect`'s listbox is `aria-multiselectable`. + +### Keyboard + +| Key | Select | Combobox | MultiSelect | SegmentedControl | +| --- | --- | --- | --- | --- | +| Typing | type-ahead on labels (Radix's own) | filters and opens | filters and opens | — | +| ArrowDown / ArrowUp | open, then move | open, then move (no wrap) | open, then move (no wrap) | — | +| ArrowLeft / ArrowRight | — | caret | caret | move focus, looping | +| Home / End | first / last option | first / last while open; caret while closed | as Combobox | first / last item | +| Enter | select and close | select and close; never submits the form while open | toggle; list stays open; never submits while open | select the focused item | +| Space | select | types a space | types a space | select the focused item | +| Escape | close, no change | close, restore the selected label | close, clear the query | — | +| Tab | — (Radix keeps focus in the open list) | close, restore the label | close, clear the query | leaves the group | +| Backspace | — | edits the text | edits the text; in an empty input removes the last chip | — | + +Focus alone never opens a Combobox or MultiSelect; a click on the input +does (and in a MultiSelect a press on the frame's padding or a chip's label +focuses the input and opens it too). Home and End jump in the open list but +edit the caret while it is closed: the spec's "Home and End jump while a +list is open" over the APG's caret-only reading. + +### Hand-off to P1-T06 + +`Field` and `useFieldControl` come with P1-T06 (#2416, not merged). Until +then nothing here imports from it, and the seam is the props: `Select`, +`Combobox` and `MultiSelect` accept `id`, `aria-describedby`, `invalid` and +`disabled` on their trigger or input, and `SegmentedControl` on its group, +matching `useFieldControl`'s `FieldControlInput`, so they plug in without +changes. `Field`'s `