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 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} />;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>({});
|
||||
|
||||
Reference in New Issue
Block a user