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:
2026-09-05 21:33:29 +02:00
parent 86163958d6
commit cc8cf96c19
16 changed files with 685 additions and 57 deletions
@@ -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"]);
});
});