The epic-end review found 32 verified issues. This is the first fix round: the three highs, the user-facing mediums, and the tests that were passing for the wrong reason. Docs and the remaining low-severity tail are next. The boundary rule was the worst of them. Both `no-restricted-imports` regexes were written in plain "..." literals, where `\.` is an unknown escape that collapses to the any-char `.` — so the rule read like a boundary and matched far less than one. `../../lib/api` (the barrel every feature component imports) and `../../components/Feed` (the same file `../Feed` names) both sailed through a green gate. String.raw, a third pattern for the second spelling, and a vitest over ESLint.lintText so a rule that silently matches nothing can never look like a clean codebase again. closeThenNavigate waited for exactly one reclaim popstate. With a second Back-closable layer still mounted — a palette opened over the player — the navigation unmounted that layer too, and its reconcile backed over the entry the navigation had just pushed: "go to Channels" closed the player and landed on the feed. It now waits for the LAST programmatic back, then adopts the entries the navigation buried, and falls back after 250ms rather than hanging if no popstate is ever coming. The rail's inline rename died on arrival. With no domReference, floating- ui's returnFocus falls through to a module-global stack and restores some unrelated element from an earlier popover — blurring the field into its own commit-and-close, from the second popover of a session onward. The context menu now owns the restore: back to the row, but only if the row is still there and nothing else has claimed focus. Also: - Backspace under the cheat sheet stopped Plex playback and dropped to the grid; `back` now defers to any layer above, like `escape` beside it. - A move invalidated only the source list, so the target kept serving pre-move contents for 30s while the rail already counted N+1. - Every move error a user can reach is a 404, which `req` leaves to the caller — so the row snapped back in silence and the shipped moveErrors strings were unreachable. - swallowClick armed on long-press but was disarmed only by the click it was armed for; any press that produced no click ate the next tap on the whole list. - The context menu stayed parked over whatever row scrolled under it. - overflow-clip on the chip lanes ate the focus ring's top and bottom. - "Why is this off" rode on disabled rows, which show no tooltip and take no focus: a new `inert` item keeps the reason readable. - Shift+F10 was advertised app-wide but wired only on Playlists; it has its own scope now, and useContextMenu matches through the shared parser instead of its own literals. - Mod+K could not close the palette, `h` machine-gunned on auto-repeat, and both were bound above the branch that mounts their overlays. - Palette options were in the tab order under aria-activedescendant, and a scroll-synthesised mousemove stole the keyboard selection. - The OSD live region was created together with its own text. - Ending a rename stranded focus on <body>. - The Button variants' hover halves are `enabled:` — the one migrated site that had guarded its own lost the guard on the way in. - HU builtinLocked named the list "Watch later" in a Hungarian sentence. Tests: the chip-lane contract counted per FILE, so a second hand-rolled lane in an already-compliant file was invisible; the closeThenNavigate ordering test only passed on a popstate left behind by its neighbours and failed alone, so it moved to its own file.
163 lines
5.4 KiB
TypeScript
163 lines
5.4 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 } 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 closeThenNavigate), 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");
|
|
});
|
|
|
|
// 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"]);
|
|
});
|
|
});
|