Files
siftlode/frontend/src/components/ui/CommandPalette.dom.test.tsx
T
peter 6088e476a7 fix(design): the command palette's active row takes the menus' roving highlight, not an invisible bg-card; the Plex typeahead's hand copy of it goes (E28)
The palette row was ~1.07:1 on its glass (B3 names bg-card hovers a violation). ROVING_HIGHLIGHT is
exported from AnchoredPopover for the listboxes that are not PopoverItems; a contract holds that no file
paints the fill itself and every hand-built option row takes the recipe.
2026-09-25 23:51:39 +02:00

349 lines
12 KiB
TypeScript

// @vitest-environment jsdom
//
// Contract of the jump-to palette: filtering (word-wise and accent-insensitive), arrow/Enter
// selection over the FLAT result list, and closing before the command runs. Items are plain props
// (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough.
import { useState } from "react";
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import CommandPalette, { type CommandGroup } from "./CommandPalette";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
beforeAll(() => {
// jsdom has no layout, so scrollIntoView is missing on the elements the active-row effect touches.
Element.prototype.scrollIntoView ??= () => {};
});
afterEach(cleanup);
// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before
// running the command (see closeTopOverlayThenRun), so the command lands a tick later — hence the waits.
const ran: string[] = [];
const groups = (): CommandGroup[] => [
{
id: "modules",
label: "Modules",
items: [
{ id: "page:feed", label: "Feed", run: () => ran.push("page:feed") },
{ id: "page:plex", label: "Plex", keywords: "media library", run: () => ran.push("plex") },
],
},
{
id: "channels",
label: "Channels",
items: [
{ id: "ch:1", label: "TheVR Podcast", hint: "@thevr", run: () => ran.push("ch:1") },
{ id: "ch:2", label: "Pénzügyi Fitnesz", run: () => ran.push("ch:2") },
],
},
];
// The palette must REALLY close, not just report it: `choose` waits for the Back-to-close entry
// that only a genuine unmount reclaims, so a harness that keeps it mounted would never run anything.
function Harness() {
const [isOpen, setIsOpen] = useState(true);
return (
<CommandPalette
open={isOpen}
onClose={() => {
ran.push("closed");
setIsOpen(false);
}}
groups={groups()}
/>
);
}
const open = () => {
ran.length = 0;
return render(<Harness />);
};
const rows = () =>
[...document.querySelectorAll("[data-cmd-id]")].map((el) => el.getAttribute("data-cmd-id"));
/** The virtually-selected row, read the way a screen reader would. */
const selected = () =>
document.querySelector("[aria-selected='true']")?.getAttribute("data-cmd-id") ?? null;
const field = () => screen.getByRole("combobox", { name: "palette.placeholder" });
const type = (value: string) => fireEvent.change(field(), { target: { value } });
const press = (key: string) => fireEvent.keyDown(field(), { key });
describe("CommandPalette", () => {
it("renders nothing while closed", () => {
render(<CommandPalette open={false} onClose={() => {}} groups={groups()} />);
expect(screen.queryByText("palette.title")).toBeNull();
});
it("lists every group until you type", () => {
open();
expect(rows()).toEqual(["page:feed", "page:plex", "ch:1", "ch:2"]);
});
// You half-remember a name; you should not have to remember the word order or the spacing.
it("matches on each word, in any order", () => {
open();
type("pod thevr");
expect(rows()).toEqual(["ch:1"]);
});
// Bilingual app, hurried typing: nobody reaches for the accent keys.
it("matches without accents", () => {
open();
type("penzugyi");
expect(rows()).toEqual(["ch:2"]);
});
it("matches hidden keywords and the group name", () => {
open();
type("media");
expect(rows()).toEqual(["page:plex"]);
type("channels");
expect(rows()).toEqual(["ch:1", "ch:2"]);
});
it("says so when nothing matches", () => {
open();
type("zzzz");
expect(rows()).toEqual([]);
expect(screen.getByText("palette.empty")).toBeTruthy();
});
it("selects the first row and points the input at it", () => {
open();
expect(selected()).toBe("page:feed");
expect(
screen
.getByRole("combobox", { name: "palette.placeholder" })
.getAttribute("aria-activedescendant"),
).toContain("page:feed");
});
// E28 F35: the active row wore `bg-card`, ~1.07:1 on the palette's glass. It is the menus' row now.
it("marks the active row with the menus' roving highlight, and only that row", () => {
open();
const rows = screen.getAllByRole("option");
const active = rows.filter((r) => r.hasAttribute("data-active"));
expect(active).toHaveLength(1);
expect(active[0]!.getAttribute("aria-selected")).toBe("true");
for (const r of rows) {
expect(r.className).toContain("data-active:bg-accent/35");
expect(r.className).not.toMatch(/(^|\s)bg-card(\s|$)/);
}
});
// The arrows walk the flat result list, not the sections — crossing a group heading is invisible.
it("walks results with the arrows, across group boundaries", () => {
open();
press("ArrowDown");
press("ArrowDown");
expect(selected()).toBe("ch:1");
});
it("wraps in both directions", () => {
open();
press("ArrowUp");
expect(selected()).toBe("ch:2");
press("ArrowDown");
expect(selected()).toBe("page:feed");
});
it("keeps the selection valid when the filter shrinks the list", () => {
open();
press("ArrowDown");
press("ArrowDown");
press("ArrowDown"); // ch:2
type("thevr"); // one row left; the old index is out of range
expect(selected()).toBe("ch:1");
});
// No test in THIS file goes through the full close→reclaim→run cycle: jsdom gives a file one
// session history and history.ts's overlay bookkeeping is module-global, so the cycle only holds
// in isolation — it lives in CommandPalette.ordering.dom.test.tsx. Which command Enter picks is
// covered by the selection assertions above; this checks it chooses at all.
it("chooses the selected row on Enter", () => {
open();
press("ArrowDown");
press("Enter");
expect(ran).toEqual(["closed"]);
});
});
// The owner's case (E11 S3): typing "theme" put a channel matched only through its handle above every
// "Theme: …" row, and Enter opened the channel.
describe("CommandPalette · ranking", () => {
const noop = () => {};
const ranked = (): CommandGroup[] => [
{
id: "channels",
label: "Channels",
items: [
{ id: "ch:melt", label: "The Meltzone Podcast", hint: "@themeltzone", run: noop },
{ id: "ch:talk", label: "Talk about themes", run: noop },
],
},
{
id: "actions",
label: "Actions",
items: [
{ id: "theme:midnight", label: "Theme: Midnight", run: noop },
{ id: "theme:forest", label: "Theme: Forest", run: noop },
],
},
];
it("ranks rows inside each group, keeps the group order, and starts on the best match", () => {
render(<CommandPalette open onClose={noop} groups={ranked()} />);
type("theme");
// label elsewhere (talk) > handle only (melt); label start (midnight = forest, group order kept)
expect(rows()).toEqual(["ch:talk", "ch:melt", "theme:midnight", "theme:forest"]);
// The highlight is what Enter takes (same `activeIndex`); the full close→run cycle cannot run in
// this file — see the note on "chooses the selected row on Enter".
expect(selected()).toBe("theme:midnight");
});
it("ranks by the worst word first, so every word in the label beats a label start plus a keyword", () => {
const groups: CommandGroup[] = [
{
id: "channels",
label: "Channels",
items: [
{ id: "weekly", label: "Podcast Weekly", keywords: "melt", run: noop },
{ id: "melt", label: "The Meltzone Podcast", run: noop },
],
},
];
render(<CommandPalette open onClose={noop} groups={groups} />);
type("podcast melt");
expect(rows()).toEqual(["melt", "weekly"]);
});
// The channel and playlist lists load after the palette opens — often while you are already typing.
it("follows the best match as a list lands, but keeps a row the arrows picked", () => {
const early: CommandGroup[] = [ranked()[0]!];
const r = render(<CommandPalette open onClose={noop} groups={early} />);
type("theme");
expect(selected()).toBe("ch:talk");
r.rerender(<CommandPalette open onClose={noop} groups={ranked()} />);
expect(selected()).toBe("theme:midnight");
press("ArrowDown"); // theme:forest, picked
r.rerender(<CommandPalette open onClose={noop} groups={[...ranked()]} />);
expect(selected()).toBe("theme:forest");
type("them"); // a new query goes back to following the best match
expect(selected()).toBe("theme:midnight");
});
});
describe("CommandPalette · accessibility", () => {
it("names each group by its heading", () => {
open();
const channels = screen.getByRole("group", { name: "Channels" });
expect(
[...channels.querySelectorAll("[role='option']")].map((o) => o.getAttribute("data-cmd-id")),
).toEqual(["ch:1", "ch:2"]);
expect(screen.getByRole("group", { name: "Modules" })).toBeTruthy();
});
it("is expanded only while there is something to choose, and says 'nothing' outside the listbox", () => {
open();
expect(field().getAttribute("aria-expanded")).toBe("true");
type("zzzz");
expect(field().getAttribute("aria-expanded")).toBe("false");
const listbox = screen.getByRole("listbox", { hidden: true });
expect(listbox.hidden).toBe(true);
// aria-controls still names a real element
expect(document.getElementById(field().getAttribute("aria-controls")!)).toBe(listbox);
expect(listbox.contains(screen.getByText("palette.empty"))).toBe(false);
});
it("announces the result count politely, once the typing pauses", async () => {
open();
const status = screen.getByRole("status");
expect(status.textContent).toBe(""); // nothing to announce on a blank field
type("the");
expect(status.textContent).toBe(""); // not per keystroke
await waitFor(() => expect(status.textContent).toBe("palette.results"));
type("zzzz");
await waitFor(() => expect(status.textContent).toBe("palette.empty"));
type("");
expect(status.textContent).toBe("");
});
});
// Owner request (E11): with 461 rows and ~11 in view, the arrows alone were a slog. PageUp/PageDown
// move by what fits in the list, and — unlike the arrows — stop at the ends instead of wrapping.
describe("CommandPalette · paging", () => {
const many = (): CommandGroup[] => [
{
id: "rows",
label: "Rows",
items: Array.from({ length: 12 }, (_, i) => ({
id: `r${i}`,
label: `Row ${i}`,
run: () => {},
})),
},
];
const openMany = () => render(<CommandPalette open onClose={() => {}} groups={many()} />);
const rect = (top: number, height: number) =>
({
top,
bottom: top + height,
height,
left: 0,
right: 100,
width: 100,
x: 0,
y: top,
}) as DOMRect;
it("pages by the rows in view minus one, and stops at both ends", () => {
// The list shows 100px and the rows are 30px tall: 3 fit, so a page is 2 rows.
const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
this: Element,
) {
if (this.getAttribute("role") === "listbox") return rect(0, 100);
const all = [...document.querySelectorAll('[role="option"]')];
return rect(all.indexOf(this) * 30, 30);
});
// Restored in `finally`: a spy left behind by a failing assertion fails every later test too.
try {
openMany();
press("PageDown");
expect(selected()).toBe("r2");
press("PageDown");
expect(selected()).toBe("r4");
for (let i = 0; i < 10; i++) press("PageDown");
expect(selected()).toBe("r11"); // no wrap to the top
press("PageUp");
expect(selected()).toBe("r9");
for (let i = 0; i < 10; i++) press("PageUp");
expect(selected()).toBe("r0");
} finally {
spy.mockRestore();
}
});
it("steps a fixed page when there is no layout to measure", () => {
openMany();
press("PageDown");
expect(selected()).toBe("r10");
press("PageDown");
expect(selected()).toBe("r11");
});
it("leaves Home and End to the text field", () => {
openMany();
press("PageDown");
expect(fireEvent.keyDown(field(), { key: "Home" })).toBe(true); // not preventDefault-ed
expect(fireEvent.keyDown(field(), { key: "End" })).toBe(true);
expect(selected()).toBe("r10");
});
});