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.
349 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|