diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3252da5d..9a0f239b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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. */} - {/* 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. */} + {meQuery.data && !meQuery.data.is_demo && } {aboutOpen && ( diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx new file mode 100644 index 00000000..500ef172 --- /dev/null +++ b/frontend/src/components/AppCommandPalette.tsx @@ -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(() => { + 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: , + // 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: , + 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: , + 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: , + run: toggleKeyboardHelp, + }, + ...SCHEMES.map((s) => ({ + id: `action:theme:${s.id}`, + label: t("palette.theme", { name: s.name }), + icon: , + 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 ; +} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 9f738691..c9666d11 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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" > diff --git a/frontend/src/components/ui/CommandPalette.dom.test.tsx b/frontend/src/components/ui/CommandPalette.dom.test.tsx new file mode 100644 index 00000000..9a2e6244 --- /dev/null +++ b/frontend/src/components/ui/CommandPalette.dom.test.tsx @@ -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(); + 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 ( + { + ran.push("closed"); + setIsOpen(false); + }} + groups={groups()} + /> + ); +} + +const open = () => { + ran.length = 0; + return render(); +}; + +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( {}} 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"]); + }); +}); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx new file mode 100644 index 00000000..98860c76 --- /dev/null +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -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(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 { + 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" + /> + + +
+ {flat.length === 0 ? ( +

{t("palette.empty")}

+ ) : ( + visible.map((g) => ( +
+
+ {g.label} +
+ {g.items.map((item) => ( + + ))} +
+ )) + )} +
+ + ); +} diff --git a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx index 21e33233..bf6ae9dc 100644 --- a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx +++ b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx @@ -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(); @@ -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({});