A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass. First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c. - `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k` step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next item takes it, so the player's focus restore lands back on the card. The first `j` takes the first card you can see, not the top of the list. - `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in); `VirtualFeed` turns it on, so the feed, the library and the channel page all have it. - Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open, enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the Hungarian layout. - `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends its first Escape on its panel). - Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences the page keys. - Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/ link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome). - Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable module; opened over a player or dialog it leaves out the page-level keys and the context-menu key, which nothing can reach there (the latter pre-existing since E10). - CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` + `data-nav-open`, not a private arrow-key handler).
178 lines
7.4 KiB
TypeScript
178 lines
7.4 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 "./PlexProvider";
|
|
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.
|
|
useEffect(() => setHi(-1), [dq, activeField, scope]);
|
|
// 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
|
|
}
|
|
/>
|
|
);
|
|
}
|