Files
siftlode/frontend/src/components/ui/CommandPalette.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

297 lines
13 KiB
TypeScript

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 <CommandPaletteBody onClose={onClose} groups={groups} />;
}
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<string | null>(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<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(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 <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;
const from = Math.max(activeIndex, 0);
setPicked(flat[(from + step + flat.length) % flat.length]!.id);
} else if (e.key === "PageDown" || e.key === "PageUp") {
// A page at a time, stopping at the ends (lib/listPaging). The list is 50dvh and group headings
// eat into it, so the page is measured. Home/End stay the field's own (caret to the query's ends).
e.preventDefault();
const list = listRef.current;
const step = pageStep(list, list?.querySelectorAll('[role="option"]') ?? []);
const to = pageTarget(
Math.max(activeIndex, 0),
e.key === "PageDown" ? step : -step,
flat.length,
);
if (to !== null) setPicked(flat[to]!.id);
} else if (e.key === "Enter") {
const item = flat[activeIndex];
if (item) {
e.preventDefault();
choose(item);
}
}
};
return (
<Modal
initialFocus="field"
title={t("palette.title")}
onClose={onClose}
maxWidth="max-w-xl"
placement="top"
>
<div className={clsx(fieldClass({ size: "lg" }), "flex items-center gap-2")}>
<Search className="w-4 h-4 shrink-0 text-muted" />
<InnerInput
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPicked(null);
}}
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. `aria-expanded`
// follows the results: with nothing to choose from there is no popup to be in.
role="combobox"
aria-expanded={flat.length > 0}
aria-controls={listId}
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
/>
</div>
<div role="status" aria-live="polite" className="sr-only">
{announced}
</div>
{/* Stays mounted (hidden) while empty, so the input's aria-controls never names a missing
element; the "nothing matches" line sits OUTSIDE it, since a listbox may hold only options
and groups. */}
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={t("palette.title")}
hidden={flat.length === 0}
className="mt-3 max-h-[50dvh] overflow-y-auto"
>
{visible.map((g) => (
<div
key={g.id}
role="group"
aria-labelledby={`${groupId}-${g.id}`}
className="mb-2 last:mb-0"
>
<div
id={`${groupId}-${g.id}`}
role="presentation"
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
>
{g.label}
</div>
{g.items.map((item) => (
<Pressable
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}
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
// these rows. Leaving them tabbable put DOM focus on a row while
// aria-activedescendant named a different one — two competing "current" options —
// and made Tab walk every result.
tabIndex={-1}
onClick={() => choose(item)}
onMouseMove={(e) => {
// Only a pointer that actually MOVED may take the selection. After the arrow keys
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
// cursor position to refresh hover — which handed the highlight straight back to
// whatever row slid under a resting cursor. The browser
// reports zero movement for exactly that synthetic event, so ask it directly:
// remembering the last position instead could not judge the FIRST
// move of a session and went stale across a close.
if (e.movementX === 0 && e.movementY === 0) return;
setPicked(item.id);
}}
// The menus' roving highlight: a `bg-card` row was ~1.07:1 on the palette's glass.
data-active={item.id === activeId ? "true" : undefined}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-muted hover:text-fg ${ROVING_HIGHLIGHT}`}
>
{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>}
</Pressable>
))}
</div>
))}
</div>
{flat.length === 0 && (
<p className="mt-3 px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
)}
</Modal>
);
}