feat(e10): a jump-to palette on Ctrl/Cmd+K

Reaching a channel three modules away cost a rail, a page and a scroll.
Now it costs a keystroke and half a word: Mod+K searches modules,
channels, playlists, the saved views applicable where you stand, and the
app-wide actions.

Scope agreed with the product owner: navigation + actions, but only the
reversible local ones (the shortcuts sheet, the theme). "Sync from
YouTube" is deliberately absent — it is a long-running job against a
quota, it wants the progress and error surface the Channels page already
gives it, and one mistyped Enter is a worse failure than the walk it
would have saved.

Matching is word-wise and accent-folded, because this is a bilingual app
and nobody reaches for the accent keys in a hurry: "pod thevr" finds
TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat
result list so crossing a group heading is invisible, and the field
carries the combobox/listbox aria tree that virtual selection needs.

ui/CommandPalette is domain-free (items are a prop, per the ui/ import
rule); AppCommandPalette supplies them. The shell is Modal again, with a
new `top` placement so a search-first dialog grows downwards instead of
jumping as results change.

Also fixes a bug the palette hit on its first run, and that PlayerModal
had already hit once: closing a dialog reclaims its Back-to-close
history entry with a programmatic history.back(), which undoes any
pushState made before it lands — so a command that navigates only closed
the palette. The rule is now closeThenNavigate() in history.ts with a
test, and PlayerModal's inline copy is gone.

The global `h` moves out of KeyboardHelp into useGlobalKeymap beside
Mod+K, so one listener owns the whole `global` scope and dispatch's
exhaustiveness keeps meaning what it says.
This commit is contained in:
2026-09-05 21:33:29 +02:00
parent 86163958d6
commit cc8cf96c19
16 changed files with 685 additions and 57 deletions
@@ -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>
);
}