Files
siftlode/frontend/src/components/ui/Combobox.dom.test.tsx
T
peter 8018cd4685 fix(ui): the platform layer speaks platform words; boxes scale with the text
- PriorityStepper labelled itself with channels.row.* and told every caller
  it orders the Feed; Combobox said plex.loading / plex.filter.moreRefine /
  sidebar.match on the feed sidebar. The generic strings moved to common.*,
  and what a priority MEANS arrives as the stepper's `hint` prop.
  uiI18n.contract.test.ts pins components/ui/ to the platform namespaces —
  the import boundary could never see an i18n key.
- 32 px-frozen box utilities (w-[150px], max-w-[1600px], left-[18px], …)
  became spacing-scale steps (w-37.5, max-w-400, left-4.5): identical at the
  default text size, and now they grow with the Settings slider instead of
  clipping or re-wrapping their own labels. An eslint rule refuses the px
  form beside the older text-[Npx] one, pinned by pxBox.contract.test.ts;
  measured in the browser that every new step is generated at its old px.
- chipLane's import-strip regex ran past a side-effect import into the next
  statement's `from`, hiding a lane between them; it now stops at the
  import's own `;`.
2026-09-19 07:46:33 +02:00

291 lines
13 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";
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("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("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(<Combobox {...props} selected={["Drama"]} />);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.queryByText("sidebar.all")).toBeNull();
// Two selections: footer appears with an any/all segment.
rerender(<FloatingTree>{<Combobox {...props} selected={["Drama", "Action"]} />}</FloatingTree>);
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(
<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
});
});
// 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(<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);
});
});