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:
@@ -35,6 +35,8 @@ import GlassTuner from "./components/GlassTuner";
|
|||||||
import ChatDock from "./components/ChatDock";
|
import ChatDock from "./components/ChatDock";
|
||||||
import Toaster from "./components/ui/Toaster";
|
import Toaster from "./components/ui/Toaster";
|
||||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||||
|
import AppCommandPalette from "./components/AppCommandPalette";
|
||||||
|
import { useGlobalKeymap } from "./lib/keymap";
|
||||||
import { StateMessage } from "./components/ui/QueryState";
|
import { StateMessage } from "./components/ui/QueryState";
|
||||||
import ErrorDialog from "./components/ErrorDialog";
|
import ErrorDialog from "./components/ErrorDialog";
|
||||||
import VersionBanner from "./components/VersionBanner";
|
import VersionBanner from "./components/VersionBanner";
|
||||||
@@ -57,6 +59,9 @@ export default function App() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { page, channelView, ytSearch } = useNavigation();
|
const { page, channelView, ytSearch } = useNavigation();
|
||||||
const { setPage, enterYtSearch } = useNavigationActions();
|
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
|
// 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
|
// 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
|
// 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. */}
|
(collapsed or expanded) without tracking its width. */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
{/* The app-wide keyboard cheat sheet + the global `h` that opens it. Mounted once, outside
|
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
|
||||||
the page area, so every surface answers "what can I press here" the same way. */}
|
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 />
|
<KeyboardHelp />
|
||||||
|
<AppCommandPalette />
|
||||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{aboutOpen && (
|
{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} />;
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { fullscreenElement } from "../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||||
import {
|
import {
|
||||||
useNativeControlsYield,
|
useNativeControlsYield,
|
||||||
useYtBigView,
|
useYtBigView,
|
||||||
@@ -756,16 +756,11 @@ export default function PlayerModal({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const cid = active.channel_id;
|
const cid = active.channel_id;
|
||||||
const cname = active.channel_title ?? undefined;
|
const cname = active.channel_title ?? undefined;
|
||||||
// Closing the player pops its own history entry (useBackToClose → history.back()).
|
// Close first, open the channel only once this player's own Back-to-close
|
||||||
// Open the channel only AFTER that pop's popstate — pushing the channel entry
|
// entry has been reclaimed — otherwise that reclaim undoes the push and
|
||||||
// before it would let the back remove it again (the bug: clicking the name only
|
// clicking the name merely closes the player. The rule lives in history.ts
|
||||||
// closed the player). One-shot listener fires after the popstate handler.
|
// now; the command palette needs exactly the same one.
|
||||||
const open = () => {
|
closeThenNavigate(onClose, () => openChannel(cid, cname));
|
||||||
window.removeEventListener("popstate", open);
|
|
||||||
openChannel(cid, cname);
|
|
||||||
};
|
|
||||||
window.addEventListener("popstate", open);
|
|
||||||
onClose();
|
|
||||||
}}
|
}}
|
||||||
className="font-medium hover:text-accent truncate text-left"
|
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 { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||||
import KeyboardHelp from "./KeyboardHelp";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -20,6 +25,17 @@ afterEach(() => {
|
|||||||
closeKeyboardHelp();
|
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. */
|
/** A stand-in for a surface that registers a scope while mounted, the way the players do. */
|
||||||
function Surface({ episode }: { episode?: boolean }) {
|
function Surface({ episode }: { episode?: boolean }) {
|
||||||
useKeyScope("plexPlayer");
|
useKeyScope("plexPlayer");
|
||||||
@@ -36,7 +52,7 @@ const descriptions = () => screen.getAllByRole("definition").map((el) => el.text
|
|||||||
|
|
||||||
describe("KeyboardHelp", () => {
|
describe("KeyboardHelp", () => {
|
||||||
it("opens and closes on the global h", () => {
|
it("opens and closes on the global h", () => {
|
||||||
render(<KeyboardHelp />);
|
render(<Host />);
|
||||||
expect(screen.queryByText("keys.title")).toBeNull();
|
expect(screen.queryByText("keys.title")).toBeNull();
|
||||||
pressH();
|
pressH();
|
||||||
expect(screen.getByText("keys.title")).toBeTruthy();
|
expect(screen.getByText("keys.title")).toBeTruthy();
|
||||||
@@ -45,7 +61,7 @@ describe("KeyboardHelp", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not open while a field has focus", () => {
|
it("does not open while a field has focus", () => {
|
||||||
render(<KeyboardHelp />);
|
render(<Host />);
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
document.body.append(input);
|
document.body.append(input);
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -57,31 +73,29 @@ describe("KeyboardHelp", () => {
|
|||||||
|
|
||||||
it("leads with the live surface and trails with the app-wide keys", () => {
|
it("leads with the live surface and trails with the app-wide keys", () => {
|
||||||
render(
|
render(
|
||||||
<>
|
<Host>
|
||||||
<Surface />
|
<Surface />
|
||||||
<KeyboardHelp />
|
</Host>,
|
||||||
</>,
|
|
||||||
);
|
);
|
||||||
pressH();
|
pressH();
|
||||||
const rows = descriptions();
|
const rows = descriptions();
|
||||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||||
expect(rows[0]).toBe("keys.playPause");
|
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", () => {
|
it("lists only the app-wide keys when no surface is registered", () => {
|
||||||
render(<KeyboardHelp />);
|
render(<Host />);
|
||||||
pressH();
|
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.
|
// "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", () => {
|
it("hides a conditional row until its surface says it applies", () => {
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<>
|
<Host>
|
||||||
<Surface />
|
<Surface />
|
||||||
<KeyboardHelp />
|
</Host>,
|
||||||
</>,
|
|
||||||
);
|
);
|
||||||
pressH();
|
pressH();
|
||||||
expect(descriptions()).not.toContain("keys.episode");
|
expect(descriptions()).not.toContain("keys.episode");
|
||||||
@@ -89,10 +103,9 @@ describe("KeyboardHelp", () => {
|
|||||||
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
|
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<>
|
<Host>
|
||||||
<Surface episode />
|
<Surface episode />
|
||||||
<KeyboardHelp />
|
</Host>,
|
||||||
</>,
|
|
||||||
);
|
);
|
||||||
pressH();
|
pressH();
|
||||||
expect(descriptions()).toContain("keys.episode");
|
expect(descriptions()).toContain("keys.episode");
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { Fragment, useEffect } from "react";
|
import { Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import {
|
import {
|
||||||
closeKeyboardHelp,
|
closeKeyboardHelp,
|
||||||
dispatch,
|
|
||||||
formatChord,
|
formatChord,
|
||||||
helpLines,
|
helpLines,
|
||||||
toggleKeyboardHelp,
|
|
||||||
useActiveScopes,
|
useActiveScopes,
|
||||||
useHelpConditionValues,
|
useHelpConditionValues,
|
||||||
useKeyboardHelpOpen,
|
useKeyboardHelpOpen,
|
||||||
} from "../../lib/keymap";
|
} 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
|
// 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
|
// 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 scopes = useActiveScopes();
|
||||||
const conditions = useHelpConditionValues();
|
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;
|
if (!open) return null;
|
||||||
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
|
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
|
||||||
// a promise the player does not keep.
|
// a promise the player does not keep.
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ export default function Modal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
/** `drawer` slides in from the right edge and runs full height; everything else about the shell —
|
/** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top
|
||||||
* the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
|
* of the viewport (where a search-first dialog like the command palette belongs, so the list grows
|
||||||
* is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
|
* downwards instead of the whole thing jumping as results change). Everything else about the shell
|
||||||
* two shells drift apart. */
|
* — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A
|
||||||
placement?: "center" | "drawer";
|
* placement is not a second dialog implementation: duplicating that behaviour is exactly how two
|
||||||
|
* shells drift apart. */
|
||||||
|
placement?: "center" | "drawer" | "top";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
@@ -114,10 +116,15 @@ export default function Modal({
|
|||||||
}, [restoreTo]);
|
}, [restoreTo]);
|
||||||
|
|
||||||
const drawer = placement === "drawer";
|
const drawer = placement === "drawer";
|
||||||
|
const top = placement === "top";
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
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) => {
|
onMouseDown={(e) => {
|
||||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
"stats": "Stats for nerds",
|
"stats": "Stats for nerds",
|
||||||
"back": "Stop & back to feed",
|
"back": "Stop & back to feed",
|
||||||
"browseBack": "Back one level",
|
"browseBack": "Back one level",
|
||||||
"closePlayer": "Close the player"
|
"closePlayer": "Close the player",
|
||||||
|
"palette": "Open the jump-to palette"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
"stats": "Statisztika (nerdöknek)",
|
"stats": "Statisztika (nerdöknek)",
|
||||||
"back": "Leállítás és vissza a feedre",
|
"back": "Leállítás és vissza a feedre",
|
||||||
"browseBack": "Vissza egy szintet",
|
"browseBack": "Vissza egy szintet",
|
||||||
"closePlayer": "Lejátszó bezárása"
|
"closePlayer": "Lejátszó bezárása",
|
||||||
|
"palette": "Ugrás-paletta megnyitása"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// no reconcile-driven history.back() runs and the shared module state stays clean between tests.
|
// no reconcile-driven history.back() runs and the shared module state stays clean between tests.
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
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.
|
// Let the mount effect's coalesced reconcile microtask settle.
|
||||||
const settle = () =>
|
const settle = () =>
|
||||||
@@ -87,3 +87,28 @@ describe("useBackToExit back-ladder", () => {
|
|||||||
rg.unmount();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
/** 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. */
|
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
|
||||||
export function useBackToClose(onClose: () => void): void {
|
export function useBackToClose(onClose: () => void): void {
|
||||||
|
|||||||
@@ -143,11 +143,15 @@ describe("dispatch", () => {
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
// The handler map is the global scope's routable ids only — `contextMenu` is absent from it,
|
// 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.
|
// 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: "F10", shiftKey: true }), "global", handlers)).toBe(false);
|
||||||
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
|
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
|
||||||
expect(dispatch(press({ key: "h" }), "global", handlers)).toBe(true);
|
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", () => {
|
it("ignores keys belonging to another scope", () => {
|
||||||
@@ -203,7 +207,7 @@ describe("helpLines", () => {
|
|||||||
it("orders scopes as asked, not as tabled", () => {
|
it("orders scopes as asked, not as tabled", () => {
|
||||||
const first = (scopes: Parameters<typeof helpLines>[0]) => helpLines(scopes)[0]?.labelKey;
|
const first = (scopes: Parameters<typeof helpLines>[0]) => helpLines(scopes)[0]?.labelKey;
|
||||||
expect(first(["plexBrowse", "global"])).toBe("keys.browseBack");
|
expect(first(["plexBrowse", "global"])).toBe("keys.browseBack");
|
||||||
expect(first(["global", "plexBrowse"])).toBe("keys.help");
|
expect(first(["global", "plexBrowse"])).toBe("keys.palette");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const SHORTCUTS = [
|
|||||||
// --- global ---------------------------------------------------------------------------------
|
// --- global ---------------------------------------------------------------------------------
|
||||||
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
|
// `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.
|
// 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" },
|
{ 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
|
// 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.
|
// 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). */
|
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
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. */
|
/** Answers for the `when` conditions, published by the surface that knows them. */
|
||||||
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
||||||
const helpConditions = createStore<HelpConditions>({});
|
const helpConditions = createStore<HelpConditions>({});
|
||||||
|
|||||||
Reference in New Issue
Block a user