- test_feed_views: extend _seed_tagged so a channel carries 2+ tags in a non-topic category, and add language-AND / other-AND equivalence cases — the per-category any/all generalization was only exercised for topic. - tagModes.test: coerceTagModes migration (legacy global tagMode -> topic), per-category preservation, invalid-value coercion, defaults. - urlState.dom.test: per-category tag-mode URL round-trip + legacy ?tagmode=and migration into the topic slot. - Combobox.dom.test: the any/all match footer (hidden below 2 selections, reports a mode change at 2+). - chipOverflow.test: the all/some/count fit boundaries and the +N reservation.
250 lines
11 KiB
TypeScript
250 lines
11 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("plex.filter.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
|
|
});
|
|
});
|