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
+26 -1
View File
@@ -11,7 +11,7 @@
// no reconcile-driven history.back() runs and the shared module state stays clean between tests.
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useBackToClose, useBackToExit } from "./history";
import { closeThenNavigate, useBackToClose, useBackToExit } from "./history";
// Let the mount effect's coalesced reconcile microtask settle.
const settle = () =>
@@ -87,3 +87,28 @@ describe("useBackToExit back-ladder", () => {
rg.unmount();
});
});
describe("closeThenNavigate", () => {
// The bug it exists for: an overlay's unmount reclaims its Back-to-close entry with a programmatic
// history.back(). A pushState made before that lands is undone by it, so a dialog command that
// navigates does nothing but close. PlayerModal hit it once; the command palette hit it again.
it("closes first and navigates only after the reclaim lands", () => {
const order: string[] = [];
closeThenNavigate(
() => order.push("close"),
() => order.push("navigate"),
);
expect(order).toEqual(["close"]);
window.dispatchEvent(new PopStateEvent("popstate"));
expect(order).toEqual(["close", "navigate"]);
});
it("navigates exactly once, however many pops follow", () => {
const navigate = vi.fn();
closeThenNavigate(() => {}, navigate);
window.dispatchEvent(new PopStateEvent("popstate"));
window.dispatchEvent(new PopStateEvent("popstate"));
expect(navigate).toHaveBeenCalledTimes(1);
});
});
+21
View File
@@ -187,6 +187,27 @@ function ensureOverlayListener(): void {
});
}
/** Close an overlay that holds a `useBackToClose` entry and THEN navigate — in that order, waiting
* for the browser to confirm it.
*
* The trap: closing unmounts the overlay, whose cleanup reconciles the history by issuing a
* programmatic `history.back()`. A `pushState` made before that lands (i.e. anything `setPage` /
* `openChannel` does synchronously) is simply undone by it, so the navigation silently does
* nothing. PlayerModal hit this first — clicking a video's channel name only closed the player —
* and the command palette hit it again the day it was written, which is why the fix lives here
* instead of being re-derived at each call site.
*
* Only call this from INSIDE an overlay that uses `useBackToClose`: the popstate we wait for is
* that overlay's own entry being reclaimed, and without one it never arrives. */
export function closeThenNavigate(close: () => void, navigate: () => void): void {
const once = () => {
window.removeEventListener("popstate", once);
navigate();
};
window.addEventListener("popstate", once);
close();
}
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
export function useBackToClose(onClose: () => void): void {
+7 -3
View File
@@ -143,11 +143,15 @@ describe("dispatch", () => {
const calls: string[] = [];
// The handler map is the global scope's routable ids only — `contextMenu` is absent from it,
// which is the compile-time half of the same claim; tsc rejects passing one.
const handlers = { help: () => calls.push("help") };
const handlers = {
help: () => void calls.push("help"),
palette: () => void calls.push("palette"),
};
expect(dispatch(press({ key: "F10", shiftKey: true }), "global", handlers)).toBe(false);
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
expect(dispatch(press({ key: "h" }), "global", handlers)).toBe(true);
expect(calls).toEqual(["help"]);
expect(dispatch(press({ key: "k", ctrlKey: true }), "global", handlers)).toBe(true);
expect(calls).toEqual(["help", "palette"]);
});
it("ignores keys belonging to another scope", () => {
@@ -203,7 +207,7 @@ describe("helpLines", () => {
it("orders scopes as asked, not as tabled", () => {
const first = (scopes: Parameters<typeof helpLines>[0]) => helpLines(scopes)[0]?.labelKey;
expect(first(["plexBrowse", "global"])).toBe("keys.browseBack");
expect(first(["global", "plexBrowse"])).toBe("keys.help");
expect(first(["global", "plexBrowse"])).toBe("keys.palette");
});
});
+31
View File
@@ -51,6 +51,7 @@ export const SHORTCUTS = [
// --- global ---------------------------------------------------------------------------------
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
// have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning.
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette" },
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
// Matched by `useContextMenu` on the focused row, not by a window-level dispatch: which row the
// menu belongs to is a property of focus, not of the app.
@@ -242,6 +243,36 @@ export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
// ── The command palette ───────────────────────────────────────────────────────────────────────
const paletteOpen = createStore(false);
export const closePalette = (): void => paletteOpen.set(false);
export const usePaletteOpen = (): boolean => paletteOpen.use();
/** Bind the `global` scope, once, for the whole app. It lives here rather than inside either
* overlay because both are opened by it: a listener that sat in one of them would have to route a
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
* calls this; the overlays are pure renderers of their store. */
export function useGlobalKeymap(): void {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
dispatch(e, "global", {
help: (ev) => {
ev.preventDefault();
helpOpen.set((v) => !v);
},
palette: (ev) => {
// Mod+K is the browser's "focus the search bar" in a few builds; we want it.
ev.preventDefault();
paletteOpen.set((v) => !v);
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
/** Answers for the `when` conditions, published by the surface that knows them. */
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
const helpConditions = createStore<HelpConditions>({});