// @vitest-environment jsdom // // The universal combobox (chips + typed search field over the AnchoredPopover `combobox` mode). Covers // chip removal, open-on-focus, client filter, toggling, Backspace-removes-last, the big-vocabulary cap, // single-select, and the combobox ARIA the old hand-rolled control lacked (role=combobox, aria-expanded, // and a virtual aria-activedescendant that keeps DOM focus on the input). i18n is key-passthrough; the // floating-ui recipe (ActionMenu's): stub the two observers autoUpdate wires, wrap in a FloatingTree, // and query the hidden surface with `{ hidden: true }` since jsdom never positions it. import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); import Combobox from "./Combobox"; afterEach(cleanup); const OPTIONS = [ { value: "Drama", count: 120 }, { value: "Action", count: 90 }, { value: "Comedy", count: 60 }, ]; function wrap(ui: React.ReactElement) { return render({ui}); } describe("Combobox", () => { it("renders selected values as removable chips and removes on ✕", () => { const onChange = vi.fn(); wrap(); expect(screen.getByText("Drama")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "common.remove" })); expect(onChange).toHaveBeenCalledWith([]); // Drama removed }); it("exposes combobox ARIA: role=combobox, aria-expanded toggling, aria-controls when open", () => { wrap(); const input = screen.getByRole("combobox"); expect(input.getAttribute("aria-expanded")).toBe("false"); expect(input.getAttribute("aria-controls")).toBeNull(); fireEvent.focus(input); expect(input.getAttribute("aria-expanded")).toBe("true"); // aria-controls now points at the listbox surface. const controls = input.getAttribute("aria-controls"); expect(controls).toBeTruthy(); expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox"); }); it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => { // Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's // onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was // always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be // typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it. wrap(); const input = screen.getByRole("combobox"); const field = input.parentElement as HTMLElement; expect(document.activeElement).not.toBe(input); fireEvent.mouseDown(field); // press the field padding, not the input itself expect(document.activeElement).toBe(input); }); it("keeps the option list unmounted until the field is focused", () => { wrap(); expect(screen.queryByRole("option", { hidden: true })).toBeNull(); fireEvent.focus(screen.getByRole("combobox")); expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3); }); it("filters the options by the typed query", () => { wrap(); const field = screen.getByRole("combobox"); fireEvent.focus(field); fireEvent.change(field, { target: { value: "com" } }); const items = screen.getAllByRole("option", { hidden: true }); expect(items.length).toBe(1); expect(items[0]?.textContent).toContain("Comedy"); }); it("marks a selected option aria-selected and toggles another on", () => { const onChange = vi.fn(); wrap(); fireEvent.focus(screen.getByRole("combobox")); const drama = screen .getAllByRole("option", { hidden: true }) .find((el) => el.textContent?.includes("Drama")); expect(drama?.getAttribute("aria-selected")).toBe("true"); fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]); }); it("moves an aria-activedescendant on ArrowDown while DOM focus stays on the input", () => { wrap(); const input = screen.getByRole("combobox"); input.focus(); fireEvent.focus(input); fireEvent.keyDown(input, { key: "ArrowDown" }); // Virtual navigation: the input keeps DOM focus, the active option is referenced by id. expect(document.activeElement).toBe(input); expect(input.getAttribute("aria-activedescendant")).toBeTruthy(); }); it("removes the last chip on Backspace in an empty field", () => { const onChange = vi.fn(); wrap( , ); fireEvent.keyDown(screen.getByRole("combobox"), { key: "Backspace" }); expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped }); it("caps the rendered rows for a large vocabulary and shows a refine hint", () => { // A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer. const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i })); wrap(); fireEvent.focus(screen.getByRole("combobox")); expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50); expect(screen.getByText("common.moreRefine")).toBeTruthy(); // 10 hidden behind search }); it("Enter commits the pointer-highlighted option, not the arrow-active one", () => { // The visible highlight follows the pointer (hoverIndex); Enter must commit THAT row, not the bare // keyboard activeIndex — otherwise arrow-then-hover-then-Enter toggles a different option than shown. const onChange = vi.fn(); wrap(); const input = screen.getByRole("combobox"); input.focus(); fireEvent.focus(input); fireEvent.keyDown(input, { key: "ArrowDown" }); // activeIndex → 0 (Drama) const action = screen .getAllByRole("option", { hidden: true }) .find((el) => el.textContent?.includes("Action"))!; fireEvent.mouseMove(action); // hoverIndex → Action (the visibly highlighted row) fireEvent.keyDown(input, { key: "Enter" }); expect(onChange).toHaveBeenCalledWith(["Action"]); // the highlighted (hovered) row, not Drama }); it("Enter commits a hover-highlighted option even with no prior arrow key", () => { const onChange = vi.fn(); wrap(); const input = screen.getByRole("combobox"); input.focus(); fireEvent.focus(input); const comedy = screen .getAllByRole("option", { hidden: true }) .find((el) => el.textContent?.includes("Comedy"))!; fireEvent.mouseMove(comedy); // highlight via hover, activeIndex still null fireEvent.keyDown(input, { key: "Enter" }); expect(onChange).toHaveBeenCalledWith(["Comedy"]); // no longer a silent no-op }); it("async mode: controlled query + onQueryChange/onOpenChange, no client filter, selectedLabels chip", () => { const onQueryChange = vi.fn(); const onOpenChange = vi.fn(); const onChange = vi.fn(); const serverOpts = [{ value: "id1", label: "Harry Potter", count: 8 }]; wrap( , ); // The selected value's option isn't in the (server-narrowed) list, so its chip uses selectedLabels. expect(screen.getByText("Stored Collection")).toBeTruthy(); const input = screen.getByRole("combobox"); fireEvent.focus(input); expect(onOpenChange).toHaveBeenCalledWith(true); fireEvent.change(input, { target: { value: "harr" } }); expect(onQueryChange).toHaveBeenCalledWith("harr"); // controlled query propagates out // clientFilter=false: the single server option renders verbatim (not re-filtered against "har"). const opts = screen.getAllByRole("option", { hidden: true }); expect(opts.length).toBe(1); expect(opts[0]?.textContent).toContain("Harry Potter"); fireEvent.click(screen.getByText("Harry Potter")); expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces }); it("shows the any/all match footer only at 2+ selections and reports a mode change", () => { // The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears // only at >= 2, and clicking 'all' reports the mode change out. const onMatchModeChange = vi.fn(); const props = { label: "Topic", options: OPTIONS, onChange: vi.fn(), matchMode: "or" as const, onMatchModeChange, }; // One selection: no footer. const { rerender } = wrap(); fireEvent.focus(screen.getByRole("combobox")); expect(screen.queryByText("sidebar.all")).toBeNull(); // Two selections: footer appears with an any/all segment. rerender({}); fireEvent.focus(screen.getByRole("combobox")); const all = screen.getByText("sidebar.all"); expect(all).toBeTruthy(); fireEvent.click(all); expect(onMatchModeChange).toHaveBeenCalledWith("and"); }); it("single-select: picking replaces the value and closes the list", () => { const onChange = vi.fn(); wrap( , ); fireEvent.focus(screen.getByRole("combobox")); fireEvent.click(screen.getByText("Action")); expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed }); // The typed query is discarded when the list closes, and it happens IN the close — the clear used // to be an effect on `open`, which fired a render later and, in controlled mode, reported the // empty query outside any event (E27 S2). The list is for "find and pick": a leftover filter would // hide everything worth seeing the next time it opens. it("discards the typed query when the list closes, so the next open shows everything", () => { wrap(); const input = screen.getByRole("combobox"); fireEvent.focus(input); fireEvent.change(input, { target: { value: "com" } }); expect(input).toHaveProperty("value", "com"); expect(screen.queryByText("Drama")).toBeNull(); // filtered away fireEvent.keyDown(input, { key: "Escape" }); expect(input.getAttribute("aria-expanded")).toBe("false"); expect(input).toHaveProperty("value", ""); fireEvent.focus(input); expect(screen.getByText("Drama")).toBeTruthy(); // the whole list is back }); it("does not report a query change until something actually changes it", () => { // A clear driven by an effect on `open` fires at MOUNT too — `open` starts false — so a // controlled caller was told its own initial query had been cleared before the user touched // anything. Driving it from the close event instead means no event, no callback. const onQueryChange = vi.fn(); const onOpenChange = vi.fn(); wrap( , ); expect(onQueryChange).not.toHaveBeenCalled(); fireEvent.focus(screen.getByRole("combobox")); expect(onQueryChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByText("Action")); expect(onOpenChange).toHaveBeenLastCalledWith(false); expect(onQueryChange.mock.calls).toEqual([[""]]); }); }); // PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to // hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a // page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging. describe("Combobox · paging", () => { const rows = (disabled: number[] = []) => Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) })); const activeValue = (input: HTMLElement) => document.getElementById(input.getAttribute("aria-activedescendant") ?? "")?.textContent; it("moves the active option a page at a time, stopping at both ends", () => { wrap(); const input = screen.getByRole("combobox"); input.focus(); fireEvent.focus(input); expect(fireEvent.keyDown(input, { key: "PageDown" })).toBe(false); // the page does not scroll expect(activeValue(input)).toBe("Row 10"); expect(document.activeElement).toBe(input); fireEvent.keyDown(input, { key: "PageDown" }); fireEvent.keyDown(input, { key: "PageDown" }); expect(activeValue(input)).toBe("Row 24"); // no wrap to the top fireEvent.keyDown(input, { key: "PageUp" }); expect(activeValue(input)).toBe("Row 14"); }); it("never lands on a disabled option, and Enter commits where it landed", () => { const onChange = vi.fn(); wrap(); const input = screen.getByRole("combobox"); input.focus(); fireEvent.focus(input); fireEvent.keyDown(input, { key: "PageDown" }); fireEvent.keyDown(input, { key: "Enter" }); expect(onChange).toHaveBeenCalledWith(["Row 11"]); }); it("leaves the key alone while the list is closed", () => { wrap(); expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true); }); });