feat(e5): first-class combobox platform mode + universal Combobox (A11Y-1)
The E5 filter pickers were a hand-rolled floating-ui combobox (MultiSelectChips) with no combobox ARIA, no arrow-nav, and options in a body-portal — keyboard/SR users could not operate it, and the bare role=listbox/option wiring actively misled screen readers (deep-review A11Y-1, CONFIRMED 3/3 medium). Rather than one-off-hack the a11y, add combobox as a first-class mode of the AnchoredPopover platform and rebuild the pickers on a reusable component. Platform (AnchoredPopover): - new mode "combobox": reuses the whole positioning core (offset/flip/shift/size/ hide, isPositioned + transform-clobber flash guards, FloatingPortal + fullscreen re-parent, topmost-only Escape layer). Differs only in the focus model — no useClick (open is field-driven), useRole role="combobox" so Floating UI wires the full ARIA tree (input role=combobox + aria-expanded/controls/autocomplete/ activedescendant, listbox surface, option ids), and useListNavigation virtual:true so ↑/↓ move an aria-activedescendant pointer while DOM focus STAYS on the input. - PopoverSurface: combobox surfaces host the item registry but are NOT focus-trapped. - PopoverItem: virtual mode carries a stable id (the activedescendant target), never takes roving DOM focus, keeps the input focused on click (mousedown preventDefault), and its checked/selected truth wins over getItemProps' single-select guess. Component (Combobox.tsx, new): the universal react-select/Overseerr combi-input — chips-in-field + typed search + checklist. Supports multi/single select, client OR server (async) filtering, custom rows, the big-vocabulary render cap + refine footer, and a selected-label cache so a server-narrowed single-select still names its chip. Rollout: MultiSelectChips (genre/age-rating/studio) → Combobox; the CollectionPicker "+Add" popover → a single-select async Combobox (also fixes its arrow-keys-eaten story), unifying the two filter idioms. PlexSearchBox is a documented future consumer. Gate green (tsc, vitest 351, eslint, prettier, knip). Browser E2E on localdev: role=combobox confirmed on both pickers; ↑/↓ moves aria-activedescendant with focus staying on the input; Enter + click select; multi-select chips accumulate; Backspace removes the last chip; async collection server-search picks + closes. No backend change.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// @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<typeof import("react-i18next")>();
|
||||
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(<FloatingTree>{ui}</FloatingTree>);
|
||||
}
|
||||
|
||||
describe("Combobox", () => {
|
||||
it("renders selected values as removable chips and removes on ✕", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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("keeps the option list unmounted until the field is focused", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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(
|
||||
<Combobox
|
||||
label="Genre"
|
||||
options={OPTIONS}
|
||||
selected={["Drama", "Action"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
|
||||
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
||||
});
|
||||
|
||||
it("single-select: picking replaces the value and closes the list", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
options={OPTIONS}
|
||||
selected={["Drama"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user