- The saved views, the view switcher, the year filter and the table's single-choice filter mark the chosen row with MenuCheck, as DESIGN asks, not a trailing tick, a dot or a filled row; a contract test holds every checkable menu row to it. keepSelectedPaint had no caller left and is gone. - A combobox's panel carries its listbox role on an inner PopoverList (listInside), so the failed load, Loading, No matches and the 'n more' line sit beside the options, not inside the listbox. The Plex search box's Loading line likewise. - The Plex sidebar's playlist picker offers Create only once the lists have loaded: over a failed load it pushed the error and its Retry out at the first keystroke.
186 lines
8.1 KiB
TypeScript
186 lines
8.1 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";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover";
|
|
|
|
// 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-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 (round 2, F4). */}
|
|
{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}`}
|
|
>
|
|
<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" />
|
|
)}
|
|
</Pressable>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
);
|
|
}
|