import clsx from "clsx"; 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 { closeTopOverlayThenRun } from "../../lib/history"; import { pageStep, pageTarget } from "../../lib/listPaging"; import { Pressable } from "./Pressable"; import { InnerInput } from "./Input"; import { ROVING_HIGHLIGHT } from "./AnchoredPopover"; import { fieldClass } from "./fieldStyles"; // 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" idea — 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. 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, ""); } /** How well an item answers the query: `[worst word, sum of words]`, lower is better. */ type Rank = readonly [number, number]; const byRank = (a: Rank, b: Rank) => a[0] - b[0] || a[1] - b[1]; /** 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. `null` = no match. * * Each word scores 0 when the label STARTS with it, 1 when it is elsewhere in the label, 2 when only * the hint, the hidden keywords or the group name carry it. An item ranks by its worst word first, so * a row whose label holds every word beats one that needed its handle for any of them. Before this * the list was pure group order: "theme" put the channel "The Meltzone Podcast" — matched through its * handle `@themeltzone` alone — above every "Theme: …" row, and Enter opened a channel. */ function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank | null { const label = fold(item.label); const rest = fold(`${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`); let worst = 0; let sum = 0; for (const w of words) { const score = label.startsWith(w) ? 0 : label.includes(w) ? 1 : rest.includes(w) ? 2 : -1; if (score < 0) return null; worst = Math.max(worst, score); sum += score; } return [worst, sum]; } /** The result count reaches a screen reader only once the typing pauses: a polite region rewritten on * every keystroke queues one announcement per letter. */ const ANNOUNCE_DELAY_MS = 400; export default function CommandPalette({ open, onClose, groups, }: { open: boolean; onClose: () => void; groups: CommandGroup[]; }) { // Closed means UNMOUNTED, not merely hidden, and that one line replaces the effect that used to // blank the query and the picked row on every open: fresh state is what mounting already means. // It also stops the ranking memo below — which folds and scores every channel, playlist and saved // view — from re-running on every parent render while the palette is shut. if (!open) return null; return ; } function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups: CommandGroup[] }) { const { t } = useTranslation(); const listId = useId(); const groupId = useId(); const [query, setQuery] = useState(""); // The row the arrows or the pointer picked, by id; `null` follows the best match. An id rather than // an index because the lists arrive asynchronously (channels, playlists load on open), and an index // would quietly point at a different row once they land. const [picked, setPicked] = useState(null); // Ref on the highlighted row rather than a selector lookup: item ids come from domain data // (playlist names, channel handles) and would need escaping to be safe in a query string. const activeRef = useRef(null); const listRef = useRef(null); // The GROUP order stays fixed — modules, channels, playlists, views, actions read the same every // time — and the rows are ranked inside each group. The best row overall starts highlighted, so // Enter takes the best match even when it sits in a later group. const { visible, bestId } = useMemo(() => { const words = fold(query.trim()).split(/\s+/).filter(Boolean); const out: CommandGroup[] = []; let best: { id: string; rank: Rank } | null = null; for (const g of groups) { const ranked: { item: CommandItem; rank: Rank }[] = []; for (const item of g.items) { const rank = rankItem(item, g.label, words); if (rank) ranked.push({ item, rank }); } if (ranked.length === 0) continue; // `sort` is stable, so equal ranks keep the group's own order (modules in nav-rail order). ranked.sort((a, b) => byRank(a.rank, b.rank)); const top = ranked[0]!; if (!best || byRank(top.rank, best.rank) < 0) best = { id: top.item.id, rank: top.rank }; out.push({ ...g, items: ranked.map((r) => r.item) }); } return { visible: out, bestId: best?.id }; }, [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 = picked !== null && flat.some((i) => i.id === picked) ? picked : bestId; const activeIndex = flat.findIndex((i) => i.id === activeId); // Keep the highlighted row in view when the arrows walk past the fold. useEffect(() => { activeRef.current?.scrollIntoView({ block: "nearest" }); }, [activeId]); const summary = !query.trim() ? "" : flat.length === 0 ? t("palette.empty") : t("palette.results", { count: flat.length }); const [announced, setAnnounced] = useState(""); useEffect(() => { if (!summary) { // eslint-disable-next-line react-hooks/set-state-in-effect -- the announcement is DEBOUNCED on purpose (ANNOUNCE_DELAY_MS above): a polite region rewritten per keystroke queues one utterance per letter. A timer's result cannot be derived in render, and the empty branch is the same timer's cancel. setAnnounced(""); return; } const timer = window.setTimeout(() => setAnnounced(summary), ANNOUNCE_DELAY_MS); return () => window.clearTimeout(timer); }, [summary]); // 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. Only THIS dialog closes — // a command that navigates closes whatever is underneath itself, one that does not (a theme) must // leave it standing. See closeTopOverlayThenRun. const choose = (item: CommandItem) => closeTopOverlayThenRun(onClose, item.run); // These live on the input rather than in the shared keymap: they are the field's own editing keys // (like a