fix(plex): address S2 /code-review findings (typeahead focus + a11y + test)

- Decouple the typeahead's dismiss from real focus: a separate `dismissed`
  flag (set on Escape/pick, cleared on typing/refocus) replaces manually
  clearing `focused`, so closing the panel no longer wedges it shut while the
  input keeps DOM focus — typing again re-opens it (verified in-browser).
- Scroll the highlighted option into view during arrow-key nav (listRef +
  data-idx + scrollIntoView), instead of leaving listRef unused.
- Wire the input as a combobox for screen readers: role=combobox,
  aria-autocomplete/expanded/controls/activedescendant + per-option ids.
- Drop the redundant client-side .slice (the request already caps at limit=10).
- SearchBar gains an `inputProps` passthrough for the combobox ARIA.
- Add PlexSearchBox.dom.test.tsx (4 tests): per-scope pill options, suggestion
  render + keyboard pick→jumpTo, and the Escape→retype reopen regression.

Reviewer's scrollbar-blur concern was a false positive — the listbox container
is not focusable, so a mousedown on it never blurs the input.
This commit is contained in:
2026-08-16 08:21:29 +02:00
parent f9681deecf
commit c51df4e0b9
3 changed files with 220 additions and 11 deletions
+40 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
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";
@@ -29,13 +29,20 @@ export default function PlexSearchBox() {
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 && dq.length >= MIN_CHARS && activeField !== "episode";
const suggestEnabled =
focused && !dismissed && dq.length >= MIN_CHARS && activeField !== "episode";
const suggestQ = useQuery({
queryKey: qk.plexSuggest(scope, dq, activeField),
enabled: suggestEnabled,
@@ -43,31 +50,44 @@ export default function PlexSearchBox() {
api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
staleTime: 30_000,
});
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []).slice(0, SUGGEST_LIMIT) : [];
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
// A fresh query resets the highlight; keep it in range if the list shrank.
// 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" });
setFocused(false);
setDismissed(true); // close the panel; typing again re-opens it (see `dismissed`)
setHi(-1);
};
const fields = availablePlexFields(scope);
const pillLabel = t(plexFieldLabelKey(activeField));
const activeOptionId = hi >= 0 ? `${listId}-opt-${hi}` : undefined;
return (
<SearchBar
value={q}
placeholder={t("plex.searchPlaceholder")}
onChange={setQ}
onFocus={() => setFocused(true)}
// Suggestion rows preventDefault on mousedown so the input keeps focus through the click; any
// other blur (Tab, the pill, clicking away) closes the panel.
// 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;
@@ -81,9 +101,16 @@ export default function PlexSearchBox() {
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
pick(hi);
} else if (e.key === "Escape") {
setFocused(false);
setDismissed(true);
}
}}
inputProps={{
role: "combobox",
"aria-autocomplete": "list",
"aria-expanded": open,
"aria-controls": open ? listId : undefined,
"aria-activedescendant": activeOptionId,
}}
leading={
<ActionMenu
ariaLabel={t("plex.searchField.aria")}
@@ -105,6 +132,7 @@ export default function PlexSearchBox() {
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]"
@@ -115,6 +143,8 @@ export default function PlexSearchBox() {
{suggestions.map((c, i) => (
<button
key={c.id}
id={`${listId}-opt-${i}`}
data-idx={i}
type="button"
role="option"
aria-selected={hi === i}