();
@@ -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}
+
+ >
+ );
+}
+
/** 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();
+ render();
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();
+ render();
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(
- <>
+
-
- >,
+ ,
);
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();
+ render();
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(
- <>
+
-
- >,
+ ,
);
pressH();
expect(descriptions()).not.toContain("keys.episode");
@@ -89,10 +103,9 @@ describe("KeyboardHelp", () => {
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
render(
- <>
+
-
- >,
+ ,
);
pressH();
expect(descriptions()).toContain("keys.episode");
diff --git a/frontend/src/components/ui/KeyboardHelp.tsx b/frontend/src/components/ui/KeyboardHelp.tsx
index ad90085b..497ada8b 100644
--- a/frontend/src/components/ui/KeyboardHelp.tsx
+++ b/frontend/src/components/ui/KeyboardHelp.tsx
@@ -1,18 +1,17 @@
-import { Fragment, useEffect } from "react";
+import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import {
closeKeyboardHelp,
- dispatch,
formatChord,
helpLines,
- toggleKeyboardHelp,
useActiveScopes,
useHelpConditionValues,
useKeyboardHelpOpen,
} from "../../lib/keymap";
-// The app's one keyboard cheat sheet, plus the global `h` that opens it. Mounted once by App.
+// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in
+// `useGlobalKeymap`, beside the palette's Mod+K, so one listener owns the whole `global` scope.
//
// It renders the shared SHORTCUTS table filtered to the scopes that are live right now — the Plex
// player's keys while you are in the player, the app-wide ones underneath — so there is a single
@@ -31,19 +30,6 @@ export default function KeyboardHelp() {
const scopes = useActiveScopes();
const conditions = useHelpConditionValues();
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- dispatch(e, "global", {
- help: (ev) => {
- ev.preventDefault();
- toggleKeyboardHelp();
- },
- });
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, []);
-
if (!open) return null;
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
// a promise the player does not keep.
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
index 20db87a0..a38857bf 100644
--- a/frontend/src/components/ui/Modal.tsx
+++ b/frontend/src/components/ui/Modal.tsx
@@ -32,11 +32,13 @@ export default function Modal({
onClose: () => void;
children: ReactNode;
maxWidth?: string;
- /** `drawer` slides in from the right edge and runs full height; everything else about the shell —
- * the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
- * is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
- * two shells drift apart. */
- placement?: "center" | "drawer";
+ /** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top
+ * of the viewport (where a search-first dialog like the command palette belongs, so the list grows
+ * downwards instead of the whole thing jumping as results change). Everything else about the shell
+ * — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A
+ * placement is not a second dialog implementation: duplicating that behaviour is exactly how two
+ * shells drift apart. */
+ placement?: "center" | "drawer" | "top";
}) {
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
@@ -114,10 +116,15 @@ export default function Modal({
}, [restoreTo]);
const drawer = placement === "drawer";
+ const top = placement === "top";
return createPortal(
{
pressedOnBackdrop.current = e.target === e.currentTarget;
diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json
index 2df9013e..5c255351 100644
--- a/frontend/src/i18n/locales/en/keys.json
+++ b/frontend/src/i18n/locales/en/keys.json
@@ -19,5 +19,6 @@
"stats": "Stats for nerds",
"back": "Stop & back to feed",
"browseBack": "Back one level",
- "closePlayer": "Close the player"
+ "closePlayer": "Close the player",
+ "palette": "Open the jump-to palette"
}
diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json
new file mode 100644
index 00000000..75dda89d
--- /dev/null
+++ b/frontend/src/i18n/locales/en/palette.json
@@ -0,0 +1,15 @@
+{
+ "title": "Jump to…",
+ "placeholder": "Search modules, channels, playlists…",
+ "empty": "Nothing matches.",
+ "theme": "Theme: {{name}}",
+ "videoCount": "{{count}} video",
+ "videoCount_other": "{{count}} videos",
+ "groups": {
+ "modules": "Modules",
+ "channels": "Channels",
+ "playlists": "Playlists",
+ "views": "Saved views",
+ "actions": "Actions"
+ }
+}
diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json
index 8de1dd82..c8cfca4f 100644
--- a/frontend/src/i18n/locales/hu/keys.json
+++ b/frontend/src/i18n/locales/hu/keys.json
@@ -19,5 +19,6 @@
"stats": "Statisztika (nerdöknek)",
"back": "Leállítás és vissza a feedre",
"browseBack": "Vissza egy szintet",
- "closePlayer": "Lejátszó bezárása"
+ "closePlayer": "Lejátszó bezárása",
+ "palette": "Ugrás-paletta megnyitása"
}
diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json
new file mode 100644
index 00000000..88e80ca2
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/palette.json
@@ -0,0 +1,15 @@
+{
+ "title": "Ugrás…",
+ "placeholder": "Modul, csatorna, lista keresése…",
+ "empty": "Nincs találat.",
+ "theme": "Téma: {{name}}",
+ "videoCount": "{{count}} videó",
+ "videoCount_other": "{{count}} videó",
+ "groups": {
+ "modules": "Modulok",
+ "channels": "Csatornák",
+ "playlists": "Listák",
+ "views": "Mentett nézetek",
+ "actions": "Műveletek"
+ }
+}
diff --git a/frontend/src/lib/history.dom.test.ts b/frontend/src/lib/history.dom.test.ts
index 9f9be78c..f2b7e208 100644
--- a/frontend/src/lib/history.dom.test.ts
+++ b/frontend/src/lib/history.dom.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts
index 1134c58d..ec0c24b1 100644
--- a/frontend/src/lib/history.ts
+++ b/frontend/src/lib/history.ts
@@ -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 {
diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts
index 0cf903c5..44b37a84 100644
--- a/frontend/src/lib/keymap.test.ts
+++ b/frontend/src/lib/keymap.test.ts
@@ -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[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");
});
});
diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts
index 32380324..69c7ef55 100644
--- a/frontend/src/lib/keymap.ts
+++ b/frontend/src/lib/keymap.ts
@@ -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({});