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"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover"; import { cardImage } from "../lib/plexImage"; import RemoteImage from "./ui/RemoteImage"; // The Plex header search box: the shared SearchBar enriched with the 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(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 ( { 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={ {pillLabel}