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
+9 -2
View File
@@ -35,6 +35,8 @@ import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap";
import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
@@ -57,6 +59,9 @@ export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch } = useNavigationActions();
// The `global` key scope, bound once for the whole app: `h` opens the cheat sheet, Mod+K the
// jump-to palette (both rendered at the bottom of this tree).
useGlobalKeymap();
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
// that branch never renders when signed in — the token was silently dropped. Signed out, the
@@ -413,9 +418,11 @@ export default function App() {
(collapsed or expanded) without tracking its width. */}
<Toaster />
</div>
{/* The app-wide keyboard cheat sheet + the global `h` that opens it. Mounted once, outside
the page area, so every surface answers "what can I press here" the same way. */}
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
Mounted once, outside the page area, so every surface answers "what can I press here" and
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
<Suspense fallback={null}>
{aboutOpen && (
@@ -0,0 +1,158 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Keyboard, ListVideo, Palette, Tv } from "lucide-react";
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useMe } from "../lib/useMe";
import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { useNavigationActions } from "./NavigationProvider";
import { usePlaylistsActions } from "./PlaylistsProvider";
import { usePrefs } from "./PrefsProvider";
import { useSavedViews } from "./SavedViewsProvider";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
//
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
// from YouTube". 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.
//
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
export default function AppCommandPalette() {
const { t } = useTranslation();
const open = usePaletteOpen();
const me = useMe();
const { setPage, openChannel } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews();
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
const channelsQ = useQuery({
queryKey: qk.channels(),
queryFn: () => api.channels(),
enabled: open,
});
const playlistsQ = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
enabled: open,
});
const groups = useMemo<CommandGroup[]>(() => {
const out: CommandGroup[] = [];
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
if (me) {
const pages = [...moduleOrder(me), "settings" as const];
out.push({
id: "modules",
label: t("palette.groups.modules"),
items: pages.map((page) => {
const def = moduleDef(page);
const Icon = def.icon;
return {
id: `page:${page}`,
label: t(def.labelKey),
icon: <Icon className="w-4 h-4" />,
// The longer tab title is a second name for the same place ("Channel manager" for
// "Channels"), so match on it even though the row shows the short one.
keywords: def.titleKey ? t(def.titleKey) : "",
run: () => setPage(page),
};
}),
});
}
const channels = channelsQ.data ?? [];
if (channels.length > 0) {
out.push({
id: "channels",
label: t("palette.groups.channels"),
items: channels
.filter((c) => !c.hidden)
.map((c) => ({
id: `channel:${c.id}`,
label: c.title ?? c.id,
hint: c.handle ?? undefined,
icon: <Tv className="w-4 h-4" />,
keywords: c.handle ?? "",
run: () => openChannel(c.id, c.title ?? undefined),
})),
});
}
const playlists = playlistsQ.data ?? [];
if (playlists.length > 0) {
out.push({
id: "playlists",
label: t("palette.groups.playlists"),
items: playlists.map((p) => ({
id: `playlist:${p.id}`,
label: p.name,
hint: t("palette.videoCount", { count: p.item_count }),
icon: <ListVideo className="w-4 h-4" />,
run: () => {
setSelectedId(p.id);
setPage("playlists");
},
})),
});
}
if (savedViews.views.length > 0) {
out.push({
id: "views",
label: t("palette.groups.views"),
items: savedViews.views.map((v) => ({
id: `view:${v.id}`,
label: v.name,
run: () => savedViews.apply(v),
})),
});
}
out.push({
id: "actions",
label: t("palette.groups.actions"),
items: [
{
id: "action:shortcuts",
label: t("keys.title"),
icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp,
},
...SCHEMES.map((s) => ({
id: `action:theme:${s.id}`,
label: t("palette.theme", { name: s.name }),
icon: <Palette className="w-4 h-4" />,
keywords: s.id,
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
})),
],
});
return out;
}, [
t,
me,
channelsQ.data,
playlistsQ.data,
savedViews,
theme,
setTheme,
setPage,
openChannel,
setSelectedId,
]);
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
}
+6 -11
View File
@@ -45,7 +45,7 @@ import {
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import { closeThenNavigate, useBackToClose } from "../lib/history";
import {
useNativeControlsYield,
useYtBigView,
@@ -756,16 +756,11 @@ export default function PlayerModal({
onClick={() => {
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after the popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
openChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
// Close first, open the channel only once this player's own Back-to-close
// entry has been reclaimed — otherwise that reclaim undoes the push and
// clicking the name merely closes the player. The rule lives in history.ts
// now; the command palette needs exactly the same one.
closeThenNavigate(onClose, () => openChannel(cid, cname));
}}
className="font-medium hover:text-accent truncate text-left"
>
@@ -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"]);
});
});
@@ -0,0 +1,178 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import Modal from "./Modal";
import { closeThenNavigate } from "../../lib/history";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
// modules away should cost a keystroke and a word, not a rail, a page and a scroll.
//
// Domain-free on purpose: the items arrive as a prop, because `components/ui` may not import a
// module or an API slice. `AppCommandPalette` (one level up) is what knows about modules, channels
// and playlists. On the shell it reuses `Modal` in its `top` placement — same layer stack, Escape,
// focus restore and Back-to-close as every other dialog.
export interface CommandItem {
/** Unique across the whole palette; also the React key. */
id: string;
label: string;
/** Right-aligned secondary text — usually the group, so a result stays legible once filtered. */
hint?: string;
icon?: ReactNode;
/** Extra text the filter matches but the row does not show (a channel handle, an alias). */
keywords?: string;
run: () => void;
}
export interface CommandGroup {
id: string;
label: string;
items: CommandItem[];
}
/** Case- and accent-insensitive, so "audio" finds "Audió" and "penzugy" finds "pénzügy" — this is a
* bilingual app and nobody types the accents when they are in a hurry. */
function fold(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "");
}
/** Every query word must appear somewhere in the item's text. Word-wise rather than substring so
* "thevr pod" finds "TheVR Podcast" — the way you actually half-remember a name. */
function matchesQuery(item: CommandItem, groupLabel: string, words: string[]): boolean {
if (words.length === 0) return true;
const hay = fold(`${item.label} ${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
return words.every((w) => hay.includes(w));
}
export default function CommandPalette({
open,
onClose,
groups,
}: {
open: boolean;
onClose: () => void;
groups: CommandGroup[];
}) {
const { t } = useTranslation();
const listId = useId();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
// Ref on the highlighted row rather than a selector lookup: item ids come from domain data
// (playlist names, channel handles) and would need escaping to be safe in a query string.
const activeRef = useRef<HTMLButtonElement | null>(null);
// A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover
// query from last time hides everything you might want now.
useEffect(() => {
if (open) {
setQuery("");
setActive(0);
}
}, [open]);
const visible = useMemo(() => {
const words = fold(query.trim()).split(/\s+/).filter(Boolean);
return groups
.map((g) => ({ ...g, items: g.items.filter((i) => matchesQuery(i, g.label, words)) }))
.filter((g) => g.items.length > 0);
}, [groups, query]);
// One flat list behind the grouped rendering — arrow keys walk results, not sections.
const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]);
const activeId = flat[Math.min(active, flat.length - 1)]?.id;
// Keep the highlighted row in view when the arrows walk past the fold.
useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest" });
}, [activeId]);
if (!open) return null;
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run. Most commands
// navigate, and a pushState issued before that reclaim is undone by it — see closeThenNavigate.
const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run);
// These live on the input rather than in the shared keymap: they are the field's own editing keys
// (like a <select>'s arrows), not app shortcuts — and the table only describes what is global.
const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (flat.length === 0) return;
const step = e.key === "ArrowDown" ? 1 : -1;
setActive((i) => (Math.min(i, flat.length - 1) + step + flat.length) % flat.length);
} else if (e.key === "Enter") {
const item = flat[Math.min(active, flat.length - 1)];
if (item) {
e.preventDefault();
choose(item);
}
}
};
return (
<Modal title={t("palette.title")} onClose={onClose} maxWidth="max-w-xl" placement="top">
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2">
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={onInputKey}
placeholder={t("palette.placeholder")}
aria-label={t("palette.placeholder")}
// A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in
// the field, so a screen reader has to be told which row is current.
role="combobox"
aria-expanded
aria-controls={listId}
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted"
/>
</div>
<div id={listId} role="listbox" className="mt-3 max-h-[50vh] overflow-y-auto">
{flat.length === 0 ? (
<p className="px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
) : (
visible.map((g) => (
<div key={g.id} className="mb-2 last:mb-0">
<div
role="presentation"
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
>
{g.label}
</div>
{g.items.map((item) => (
<button
key={item.id}
id={`${listId}-${item.id}`}
ref={item.id === activeId ? activeRef : undefined}
data-cmd-id={item.id}
role="option"
aria-selected={item.id === activeId}
type="button"
onClick={() => choose(item)}
onMouseMove={() => setActive(flat.indexOf(item))}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
</button>
))}
</div>
))
)}
</div>
</Modal>
);
}
@@ -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");
+3 -17
View File
@@ -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.
+13 -6
View File
@@ -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(
<div
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
drawer ? "justify-end" : "items-center justify-center p-4 sm:p-6"
drawer
? "justify-end"
: top
? "items-start justify-center p-4 pt-[8vh] sm:p-6 sm:pt-[10vh]"
: "items-center justify-center p-4 sm:p-6"
}`}
onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget;
+2 -1
View File
@@ -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"
}
+15
View File
@@ -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"
}
}
+2 -1
View File
@@ -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"
}
+15
View File
@@ -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"
}
}
+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>({});