Files
siftlode/frontend/src/modules/plex/PlexSearchBox.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

188 lines
8.3 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 "../../features/header/SearchBar";
import ActionMenu from "../../components/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 "../../components/ui/Pressable";
import { SURFACE } from "../../components/ui/surfaces";
import { ROVING_HIGHLIGHT } from "../../components/ui/AnchoredPopover";
import { cardImage } from "../../lib/plexImage";
import RemoteImage from "../../components/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<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-full px-2 h-7 text-xs font-medium text-muted transition state-layer hover:text-fg touch-target"
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
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-menu mt-2 max-h-[70dvh] overflow-y-auto animate-[popIn_0.16s_ease]`}
>
{/* Beside the listbox, not in it: a listbox may own options only. */}
{suggestions.length === 0 && suggestQ.isFetching && (
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
)}
<div ref={listRef} id={listId} role="listbox" aria-label={t("plex.suggest.aria")}>
{suggestions.map((c, i) => (
<Pressable
key={c.id}
id={`${listId}-opt-${i}`}
data-idx={i}
role="option"
aria-selected={hi === i}
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
onMouseEnter={() => setHi(i)}
onClick={() => pick(i)}
// The pointer moves `hi` too, so the highlight IS the hover: the menus' roving
// recipe (AnchoredPopover's ROVING_HIGHLIGHT), one look for the mouse and the keys.
data-active={hi === i ? "true" : undefined}
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`}
>
<RemoteImage
{...cardImage(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" />
)}
</Pressable>
))}
</div>
</div>
) : null
}
/>
);
}