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
@@ -6,7 +6,12 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import KeyboardHelp from "./KeyboardHelp";
import { closeKeyboardHelp, useHelpConditions, useKeyScope } from "../../lib/keymap";
import {
closeKeyboardHelp,
useGlobalKeymap,
useHelpConditions,
useKeyScope,
} from "../../lib/keymap";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -20,6 +25,17 @@ afterEach(() => {
closeKeyboardHelp();
});
/** App's job: it binds the `global` scope (h / Mod+K) once for the whole tree. */
function Host({ children }: { children?: React.ReactNode }) {
useGlobalKeymap();
return (
<>
{children}
<KeyboardHelp />
</>
);
}
/** A stand-in for a surface that registers a scope while mounted, the way the players do. */
function Surface({ episode }: { episode?: boolean }) {
useKeyScope("plexPlayer");
@@ -36,7 +52,7 @@ const descriptions = () => screen.getAllByRole("definition").map((el) => el.text
describe("KeyboardHelp", () => {
it("opens and closes on the global h", () => {
render(<KeyboardHelp />);
render(<Host />);
expect(screen.queryByText("keys.title")).toBeNull();
pressH();
expect(screen.getByText("keys.title")).toBeTruthy();
@@ -45,7 +61,7 @@ describe("KeyboardHelp", () => {
});
it("does not open while a field has focus", () => {
render(<KeyboardHelp />);
render(<Host />);
const input = document.createElement("input");
document.body.append(input);
act(() => {
@@ -57,31 +73,29 @@ describe("KeyboardHelp", () => {
it("leads with the live surface and trails with the app-wide keys", () => {
render(
<>
<Host>
<Surface />
<KeyboardHelp />
</>,
</Host>,
);
pressH();
const rows = descriptions();
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
expect(rows[0]).toBe("keys.playPause");
expect(rows.slice(-2)).toEqual(["keys.help", "keys.contextMenu"]);
expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]);
});
it("lists only the app-wide keys when no surface is registered", () => {
render(<KeyboardHelp />);
render(<Host />);
pressH();
expect(descriptions()).toEqual(["keys.help", "keys.contextMenu"]);
expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]);
});
// "Previous / next episode" inside a movie would be a promise the player does not keep.
it("hides a conditional row until its surface says it applies", () => {
const { unmount } = render(
<>
<Host>
<Surface />
<KeyboardHelp />
</>,
</Host>,
);
pressH();
expect(descriptions()).not.toContain("keys.episode");
@@ -89,10 +103,9 @@ describe("KeyboardHelp", () => {
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
render(
<>
<Host>
<Surface episode />
<KeyboardHelp />
</>,
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.episode");