Files
siftlode/frontend/src/components/ui/Combobox.dom.test.tsx
T
peter 5ebb4cb506 fix(a11y): chips, grips, the panel tab and Back to top keep the focus somewhere real
- 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.
2026-09-27 15:01:22 +02:00

366 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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);
});
});