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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user