Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
184 lines
7.7 KiB
TypeScript
184 lines
7.7 KiB
TypeScript
import { useEffect, useId, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { ChevronDown, Film, Tv } from "lucide-react";
|
|
import SearchBar from "./SearchBar";
|
|
import ActionMenu from "./ui/ActionMenu";
|
|
import { usePlex, usePlexActions } from "./plexContext";
|
|
import { api } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
|
|
|
|
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
|
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
|
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
|
|
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
|
|
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
|
|
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
|
|
const MIN_CHARS = 2;
|
|
const SUGGEST_LIMIT = 10;
|
|
|
|
export default function PlexSearchBox() {
|
|
const { t } = useTranslation();
|
|
const { q, scope, field } = usePlex();
|
|
const { setQ, setField, setJumpTo } = usePlexActions();
|
|
const activeField = healPlexField(field, scope);
|
|
const dq = useDebounced(q.trim(), 200);
|
|
// `focused` mirrors the input's real DOM focus (only onFocus/onBlur touch it). `dismissed` is the
|
|
// separate "the user closed the panel" flag (Escape / after a pick) — kept apart so closing the
|
|
// panel never desyncs from actual focus: typing again clears `dismissed` and the panel re-opens
|
|
// without needing a blur+refocus cycle.
|
|
const [focused, setFocused] = useState(false);
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const listId = useId();
|
|
|
|
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
|
|
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
|
|
const suggestEnabled =
|
|
focused && !dismissed && dq.length >= MIN_CHARS && activeField !== "episode";
|
|
const suggestQ = useQuery({
|
|
queryKey: qk.plexSuggest(scope, dq, activeField),
|
|
enabled: suggestEnabled,
|
|
queryFn: () => api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
|
staleTime: 30_000,
|
|
});
|
|
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
|
|
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
|
|
|
|
// A fresh query resets the highlight — during THAT render, so no frame ever draws the new
|
|
// suggestion list with the old row highlighted.
|
|
const suggestKey = `${scope}|${activeField}|${dq}`;
|
|
const [hiFor, setHiFor] = useState(suggestKey);
|
|
if (hiFor !== suggestKey) {
|
|
setHiFor(suggestKey);
|
|
setHi(-1);
|
|
}
|
|
// Keep the highlighted option scrolled into view during arrow-key navigation.
|
|
useEffect(() => {
|
|
if (hi < 0) return;
|
|
listRef.current?.querySelector(`[data-idx="${hi}"]`)?.scrollIntoView({ block: "nearest" });
|
|
}, [hi]);
|
|
|
|
const pick = (i: number) => {
|
|
const c = suggestions[i];
|
|
if (!c) return;
|
|
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
|
|
setDismissed(true); // close the panel; typing again re-opens it (see `dismissed`)
|
|
setHi(-1);
|
|
};
|
|
|
|
const fields = availablePlexFields(scope);
|
|
const pillLabel = t(plexFieldLabelKey(activeField));
|
|
// Only reference an option while the listbox is actually open — a dangling aria-activedescendant
|
|
// on a collapsed combobox is an inconsistent a11y state.
|
|
const activeOptionId = open && hi >= 0 ? `${listId}-opt-${hi}` : undefined;
|
|
|
|
return (
|
|
<SearchBar
|
|
value={q}
|
|
placeholder={t("plex.searchPlaceholder")}
|
|
// Typing after a dismiss (Escape / pick) re-opens the panel.
|
|
onChange={(v) => {
|
|
setDismissed(false);
|
|
setQ(v);
|
|
}}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
setDismissed(false);
|
|
}}
|
|
onBlur={() => setFocused(false)}
|
|
onKeyDown={(e) => {
|
|
if (!open) return;
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setHi((h) => Math.min(h + 1, suggestions.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setHi((h) => Math.max(h - 1, -1));
|
|
} else if (e.key === "Enter" && hi >= 0) {
|
|
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
|
pick(hi);
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
|
|
setDismissed(true);
|
|
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
|
}
|
|
}}
|
|
inputProps={{
|
|
role: "combobox",
|
|
"aria-autocomplete": "list",
|
|
"aria-expanded": open,
|
|
"aria-controls": open ? listId : undefined,
|
|
"aria-activedescendant": activeOptionId,
|
|
}}
|
|
leading={
|
|
<ActionMenu
|
|
ariaLabel={t("plex.searchField.aria")}
|
|
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
|
trigger={
|
|
<>
|
|
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
|
|
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
|
</>
|
|
}
|
|
items={fields.map((f) => ({
|
|
id: f,
|
|
label: t(plexFieldLabelKey(f)),
|
|
onSelect: () => setField(f),
|
|
}))}
|
|
/>
|
|
}
|
|
below={
|
|
open ? (
|
|
<div
|
|
ref={listRef}
|
|
id={listId}
|
|
role="listbox"
|
|
aria-label={t("plex.suggest.aria")}
|
|
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
|
|
>
|
|
{suggestions.length === 0 && suggestQ.isFetching && (
|
|
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
|
)}
|
|
{suggestions.map((c, i) => (
|
|
<button
|
|
key={c.id}
|
|
id={`${listId}-opt-${i}`}
|
|
data-idx={i}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={hi === i}
|
|
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
|
onMouseEnter={() => setHi(i)}
|
|
onClick={() => pick(i)}
|
|
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
|
|
hi === i ? "bg-border/60" : "hover:bg-border/40"
|
|
}`}
|
|
>
|
|
<img
|
|
src={c.thumb}
|
|
alt=""
|
|
loading="lazy"
|
|
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
|
|
{c.year != null && (
|
|
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
|
|
)}
|
|
{c.type === "show" ? (
|
|
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
|
) : (
|
|
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
);
|
|
}
|