- F5: a chip's × pressed from the keyboard hands the focus to the next chip (else the previous one, else what follows) before it unmounts — in DismissibleChip, so all five chip rows get it (`focusNeighbour` in lib/useFocusKeeper). - F4: a drag grip names what it lifts (`common.order.dragNamed`: panel groups, personal views); the two generic "Drag to reorder" keys are gone. - F6: the side panel's edge tab is out of the Tab order while the card covers it, names its panel, and a keyboard pin moves the focus into the card. - F7: Back to top is no Tab stop while hidden, and a keyboard press leaves the focus on the page.
366 lines
17 KiB
TypeScript
366 lines
17 KiB
TypeScript
// @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";
|
||
import { useState } from "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.removeNamed" }));
|
||
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("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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||
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(<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("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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||
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(
|
||
<Combobox
|
||
label="Collection"
|
||
multiple={false}
|
||
clientFilter={false}
|
||
options={serverOpts}
|
||
selected={["stored-id"]}
|
||
selectedLabels={{ "stored-id": "Stored Collection" }}
|
||
query="har"
|
||
onQueryChange={onQueryChange}
|
||
onOpenChange={onOpenChange}
|
||
onChange={onChange}
|
||
/>,
|
||
);
|
||
// 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("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
|
||
});
|
||
|
||
// The typed query is discarded when the list closes. CHARACTERIZATION, not the regression guard:
|
||
// the old effect-based clear satisfies these assertions too, because RTL wraps fireEvent in act()
|
||
// and the passive effect has flushed by the time the value is read. What the move into the close
|
||
// funnel actually fixed is measured by the next test, which the old shape fails. This one is here
|
||
// because the behaviour is worth pinning: the list is for "find and pick", and 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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||
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(
|
||
<Combobox
|
||
label="Collection"
|
||
multiple={false}
|
||
options={OPTIONS}
|
||
selected={[]}
|
||
onChange={vi.fn()}
|
||
query="com"
|
||
onQueryChange={onQueryChange}
|
||
onOpenChange={onOpenChange}
|
||
clientFilter={false}
|
||
/>,
|
||
);
|
||
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.
|
||
// E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a
|
||
// picker whose pick was still being written took another pick.
|
||
// E28 review F5: a chip's × pressed from the keyboard unmounted with the focus on it — to <body>,
|
||
// out of the picker. The focus goes to the next chip's × (else the previous one, else the field).
|
||
describe("Combobox · removing a chip from the keyboard", () => {
|
||
function Harness({ initial }: { initial: string[] }) {
|
||
const [sel, setSel] = useState(initial);
|
||
return <Combobox label="Genre" options={OPTIONS} selected={sel} onChange={setSel} />;
|
||
}
|
||
const x = (name: string) =>
|
||
screen
|
||
.getAllByRole("button", { name: "common.removeNamed" })
|
||
.find((b) => b.closest("span")?.textContent?.includes(name))!;
|
||
|
||
it("hands the focus to the neighbour chip, then to the field", () => {
|
||
wrap(<Harness initial={["Drama", "Action"]} />);
|
||
x("Drama").focus();
|
||
fireEvent.click(x("Drama"), { detail: 0 });
|
||
expect(document.activeElement).toBe(x("Action"));
|
||
fireEvent.click(x("Action"), { detail: 0 });
|
||
expect(document.activeElement).toBe(screen.getByRole("combobox"));
|
||
});
|
||
});
|
||
|
||
describe("Combobox · honest states", () => {
|
||
it("says the list failed to load, not that nothing matched", () => {
|
||
wrap(
|
||
<Combobox label="User" options={[]} selected={[]} onChange={vi.fn()} error="Load failed" />,
|
||
);
|
||
fireEvent.focus(screen.getByRole("combobox"));
|
||
expect(screen.getByText("Load failed")).toBeTruthy();
|
||
expect(screen.queryByText("common.noResults")).toBeNull();
|
||
});
|
||
|
||
it("disabled: keeps the focus, opens nothing and picks nothing", () => {
|
||
const onChange = vi.fn();
|
||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} disabled />);
|
||
const field = screen.getByRole("combobox");
|
||
field.focus();
|
||
fireEvent.focus(field);
|
||
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
|
||
fireEvent.keyDown(field, { key: "ArrowDown" });
|
||
fireEvent.keyDown(field, { key: "Enter" });
|
||
expect(onChange).not.toHaveBeenCalled();
|
||
expect(field.getAttribute("aria-disabled")).toBe("true");
|
||
expect(document.activeElement).toBe(field); // not natively disabled: the focus stays put
|
||
});
|
||
});
|
||
|
||
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(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
|
||
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(<Combobox label="Channel" options={rows([10])} selected={[]} onChange={onChange} />);
|
||
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(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
|
||
expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true);
|
||
});
|
||
});
|