feat(e10): a jump-to palette on Ctrl/Cmd+K
Reaching a channel three modules away cost a rail, a page and a scroll. Now it costs a keystroke and half a word: Mod+K searches modules, channels, playlists, the saved views applicable where you stand, and the app-wide actions. Scope agreed with the product owner: navigation + actions, but only the reversible local ones (the shortcuts sheet, the theme). "Sync from YouTube" is deliberately absent — it is a long-running job against a quota, it wants the progress and error surface the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it would have saved. Matching is word-wise and accent-folded, because this is a bilingual app and nobody reaches for the accent keys in a hurry: "pod thevr" finds TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat result list so crossing a group heading is invisible, and the field carries the combobox/listbox aria tree that virtual selection needs. ui/CommandPalette is domain-free (items are a prop, per the ui/ import rule); AppCommandPalette supplies them. The shell is Modal again, with a new `top` placement so a search-first dialog grows downwards instead of jumping as results change. Also fixes a bug the palette hit on its first run, and that PlayerModal had already hit once: closing a dialog reclaims its Back-to-close history entry with a programmatic history.back(), which undoes any pushState made before it lands — so a command that navigates only closed the palette. The rule is now closeThenNavigate() in history.ts with a test, and PlayerModal's inline copy is gone. The global `h` moves out of KeyboardHelp into useGlobalKeymap beside Mod+K, so one listener owns the whole `global` scope and dispatch's exhaustiveness keeps meaning what it says.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// @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 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");
|
||||
});
|
||||
|
||||
// The ORDER is the point (closeThenNavigate): the dialog closes and gives its Back-to-close
|
||||
// history entry back BEFORE the command runs, because a command that navigates would otherwise
|
||||
// have its pushState undone by that reclaim.
|
||||
it("closes, and runs the command only once the close has landed", async () => {
|
||||
open();
|
||||
fireEvent.click(screen.getByText("TheVR Podcast"));
|
||||
expect(ran).toEqual(["closed"]);
|
||||
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]));
|
||||
});
|
||||
|
||||
// Only ONE test here can go through the full close→popstate→run cycle: jsdom gives a file a single
|
||||
// session-history entry, so the first back() consumes it and later ones fire no popstate. 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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user