diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 1c13380a..10733c9d 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -53,6 +53,30 @@ router = APIRouter(prefix="/api/plex", tags=["plex"]) _TS_CONFIG = "public.unaccent_simple" +# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE +# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to +# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column +# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions +# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)` +# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled +# separately in the query body (it targets episode rows, not the movie/show grid). +_FIELD_COLUMNS = ("title", "people", "summary", "episode") + + +def _field_recheck(model, field: str, tsq): + """A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None.""" + if field == "title": + expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, "")) + elif field == "people": + expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, "")) + elif field == "summary": + expr = func.to_tsvector( + _TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000) + ) + else: + return None + return expr.op("@@")(tsq) + @router.get("/libraries") def list_libraries( @@ -251,6 +275,7 @@ class LibraryFilters: def unified_library( scope: str = "both", q: str | None = None, + field: str = "all", sort: str = "added", sort_dir: str = "desc", show: str = "all", @@ -276,9 +301,17 @@ def unified_library( ts = to_tsquery_str(q) if q else None tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None + if field not in _FIELD_COLUMNS and field != "all": + field = "all" + # `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for + # the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter. + grid_field = field != "episode" + recheck = ( + _field_recheck if tsq is not None and field not in ("all", "episode") else None + ) selects = [] - if want_movies: + if want_movies and grid_field: st = aliased(PlexState) m_status = case( (st.status == "watched", "watched"), @@ -334,8 +367,10 @@ def unified_library( mq = mq.where(PlexItem.duration_s <= f.duration_max) if tsq is not None: mq = mq.where(PlexItem.search_vector.op("@@")(tsq)) + if recheck is not None: + mq = mq.where(recheck(PlexItem, field, tsq)) selects.append(mq) - if want_shows: + if want_shows and grid_field: agg = rollup.agg_subquery(db, user.id).subquery() tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) @@ -377,69 +412,74 @@ def unified_library( sq = _apply_meta_filters(sq, PlexShow, p) if tsq is not None: sq = sq.where(PlexShow.search_vector.op("@@")(tsq)) + if recheck is not None: + sq = sq.where(recheck(PlexShow, field, tsq)) selects.append(sq) - if not selects: - return { - "scope": scope, - "total": 0, - "offset": offset, - "limit": limit, - "items": [], - "episodes": [], - } - union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) - u = union.subquery() - total = db.scalar(select(func.count()).select_from(u)) or 0 - - if tsq is not None: - order = [u.c.rank.desc()] - else: - col = { - "title": func.lower(u.c.title), - "year": u.c.year, - "rating": u.c.rating, - "duration": u.c.duration_s, - "release": u.c.rel, - }.get(sort, u.c.added_at) - order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] - order.append(u.c.rk.desc()) - rows = db.execute( - select(u).order_by(*order).offset(offset).limit(limit) - ).all() - items = [] - for r in rows: - if r.kind == "movie": - items.append( - { - "id": r.rk, - "type": "movie", - "title": r.title, - "year": r.year, - "duration_seconds": r.duration_s, - "thumb": f"/api/plex/image/{r.rk}", - "playable": r.playable, - "status": r.status, - "position_seconds": r.position_seconds, - } - ) - else: - items.append( - { - "id": r.rk, - "type": "show", - "title": r.title, - "year": r.year, - "thumb": f"/api/plex/image/{r.rk}", - "season_count": r.season_count, - "status": r.status, - } - ) + total = 0 + if selects: + union = ( + selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) + ) + u = union.subquery() + total = db.scalar(select(func.count()).select_from(u)) or 0 + if tsq is not None: + order = [u.c.rank.desc()] + else: + col = { + "title": func.lower(u.c.title), + "year": u.c.year, + "rating": u.c.rating, + "duration": u.c.duration_s, + "release": u.c.rel, + }.get(sort, u.c.added_at) + order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] + order.append(u.c.rk.desc()) + rows = db.execute( + select(u).order_by(*order).offset(offset).limit(limit) + ).all() + + for r in rows: + if r.kind == "movie": + items.append( + { + "id": r.rk, + "type": "movie", + "title": r.title, + "year": r.year, + "duration_seconds": r.duration_s, + "thumb": f"/api/plex/image/{r.rk}", + "playable": r.playable, + "status": r.status, + "position_seconds": r.position_seconds, + } + ) + else: + items.append( + { + "id": r.rk, + "type": "show", + "title": r.title, + "year": r.year, + "thumb": f"/api/plex/image/{r.rk}", + "season_count": r.season_count, + "status": r.status, + } + ) + + # Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour) + # and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes + # target the movie/show grid, so they suppress episodes. episodes = [] - if tsq is not None and want_shows: + if tsq is not None and want_shows and field in ("all", "episode"): ep_st = aliased(PlexState) + ep_vec = ( + func.to_tsvector(_TS_CONFIG, func.coalesce(PlexItem.title, "")) + if field == "episode" + else PlexItem.search_vector + ) eps = db.execute( select(PlexItem, ep_st, PlexShow.title.label("show_title")) .outerjoin( @@ -449,9 +489,9 @@ def unified_library( .where( PlexItem.kind == "episode", PlexItem.library_id.in_(show_lib_ids), - PlexItem.search_vector.op("@@")(tsq), + ep_vec.op("@@")(tsq), ) - .order_by(func.ts_rank(PlexItem.search_vector, tsq).desc()) + .order_by(func.ts_rank(ep_vec, tsq).desc()) .limit(24) ).all() for e, s, show_title in eps: diff --git a/backend/tests/test_plex_field_scoped_search.py b/backend/tests/test_plex_field_scoped_search.py new file mode 100644 index 00000000..20da9425 --- /dev/null +++ b/backend/tests/test_plex_field_scoped_search.py @@ -0,0 +1,132 @@ +"""S2 · field-scoped search — the `field` param on /api/plex/library. + +The search box's scope-pill narrows a full-text query to ONE facet of the row: Everywhere (`all`, +the untouched weighted `search_vector`), Titles, Cast & crew (`people`), Descriptions (`summary`), +or Episodes (episode rows only, title-only). These lock that each scope is a strict narrowing of +Everywhere, that `all` is unchanged from the pre-scope behaviour, and that the Episode scope empties +the movie/show grid and only applies where shows are in scope. DB lane (`db` fixture) — skipped +without Postgres (the `search_vector` GIN + per-field `to_tsvector` recheck are real Postgres FTS). +""" +from datetime import datetime, timezone + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import ( + PlexItem, + PlexLibrary, + PlexSeason, + PlexShow, + User, +) +from fastapi.testclient import TestClient + + +def _dt(day: int) -> datetime: + return datetime(2024, 2, day, tzinfo=timezone.utc) + + +def _seed(db): + """One rare token, `zephyr`, planted in exactly one facet per row so a scope's narrowing is + unambiguous: mt has it ONLY in the title, mp ONLY in cast (people_text), ms ONLY in the summary, + and episode ep ONLY in its title (its show carries none). Everywhere matches all four; each + scoped field matches exactly its one row.""" + movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movie_lib, show_lib]) + db.flush() + mt = PlexItem(rating_key="mt", library_id=movie_lib.id, kind="movie", + title="Zephyr Rising", people_text="Alice Stone", summary="A calm coastal day.", + year=2001, added_at=_dt(1)) + mp = PlexItem(rating_key="mp", library_id=movie_lib.id, kind="movie", + title="Quiet Harbor", people_text="Zephyr Vance", summary="A calm coastal day.", + year=2002, added_at=_dt(2)) + ms = PlexItem(rating_key="ms", library_id=movie_lib.id, kind="movie", + title="Quiet Harbor Two", people_text="Alice Stone", + summary="A story about zephyr winds.", year=2003, added_at=_dt(3)) + show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Nimbus Show", + summary="Weather anthology.", people_text="Bob Cloud", + year=2004, added_at=_dt(4), child_count=1) + db.add_all([mt, mp, ms, show]) + db.flush() + season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1) + db.add(season) + db.flush() + ep = PlexItem(rating_key="ep", library_id=show_lib.id, kind="episode", title="Zephyr Episode", + show_id=show.id, season_id=season.id, season_number=1, episode_number=1, + added_at=_dt(5)) + db.add(ep) + user = User(email="plexfield@example.com") + db.add(user) + db.commit() + return {"user": user} + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + return TestClient(app).get(path) + finally: + app.dependency_overrides.clear() + + +def _ids(body): + return sorted(i["id"] for i in body["items"]) + + +class TestFieldScopedSearch: + def test_everywhere_matches_every_facet(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() + # title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out + assert _ids(body) == ["mp", "ms", "mt"] + assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] + + def test_field_all_is_identical_to_default(self, db, monkeypatch): + s = _seed(db) + default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json() + scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() + assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"] + assert default["total"] == scoped["total"] + assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]] + + def test_title_scope_narrows_to_title_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json() + assert _ids(body) == ["mt"] + assert body["total"] == 1 + assert body["episodes"] == [] # non-episode field scopes suppress the episode section + + def test_people_scope_narrows_to_cast_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json() + assert _ids(body) == ["mp"] + + def test_summary_scope_narrows_to_description_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json() + assert _ids(body) == ["ms"] + + def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json() + assert body["items"] == [] + assert body["total"] == 0 + assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] + + def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch): + s = _seed(db) + body = _get( + db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie" + ).json() + # movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty + assert body["items"] == [] + assert body["episodes"] == [] + + def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json() + assert _ids(body) == ["mp", "ms", "mt"] diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 14de9e6c..8a049fba 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -5,11 +5,11 @@ import type { Me } from "../lib/api"; import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; import ModuleName from "./ModuleName"; import SearchBar from "./SearchBar"; +import PlexSearchBox from "./PlexSearchBox"; import SyncStatus from "./SyncStatus"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; -import { usePlex, usePlexActions } from "./PlexProvider"; import { useChannels, useChannelsActions } from "./ChannelsProvider"; // The floating top header: a row of glass pills over the content — a ModuleName pill (label + @@ -34,8 +34,6 @@ export default function Header({ // "go to full history" jump is a ChannelsProvider action. const { search: playlistsSearch } = usePlaylists(); const { setSearch: setPlaylistsSearch } = usePlaylistsActions(); - const { q: plexQ } = usePlex(); - const { setQ: setPlexQ } = usePlexActions(); const { search: channelsSearch } = useChannels(); const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions(); // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so @@ -70,14 +68,7 @@ export default function Header({ /> ); } else if (page === "plex" && me.plex_enabled) { - search = ( - - ); + search = ; } else if (page === "channels") { search = ( ({ kind: "grid" }); // Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then @@ -112,6 +115,20 @@ export default function PlexBrowse() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [openPlaylist]); + // The header search typeahead picked a title → open it the same way a card click would (a movie's + // info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open. + useEffect(() => { + if (jumpTo != null) { + sub.open( + jumpTo.type === "show" + ? { kind: "show", id: jumpTo.id } + : { kind: "info", id: jumpTo.id }, + ); + setJumpTo(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jumpTo]); + // Opening a show/player replaces the grid entirely (the component returns early below), so the // scroll container resets to the top. Remember where the grid was scrolled when leaving it, and // restore it when we come back — so the browser/mouse Back from the player lands on the same card @@ -151,13 +168,14 @@ export default function PlexBrowse() { }, [sub]); const browseQ = useInfiniteQuery({ - queryKey: qk.plexLibrary(scope, dq, sortState, show, filters), + queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState), enabled: !!scope && sub.view.kind === "grid", initialPageParam: 0, queryFn: ({ pageParam }) => api.plexLibrary({ scope, q: dq || undefined, + field: fieldState, sort: sortState, show, filters, diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index 9fe36f7f..07cbe54f 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { parseSortState, type SortState } from "../lib/columnSort"; +import { parsePlexField, type PlexSearchField } from "../lib/plexSearch"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; @@ -19,13 +20,19 @@ import { useAccountRehydrate } from "../lib/useAccountRehydrate"; // search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks // into (and is never greeted by a stale value from) the feed. +// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's +// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type. +export type PlexJumpTarget = { id: string; type: "movie" | "show" }; + type PlexState = { scope: string; // movie | show | both (unified cross-library scope) show: string; sort: SortState; // {key,dir}; healed against the scope's options at the point of use filters: PlexFilters; q: string; + field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use playlistOpen: number | null; + jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title") }; interface PlexActions { @@ -34,7 +41,9 @@ interface PlexActions { setSort: (v: SortState) => void; setFilters: (f: PlexFilters) => void; setQ: (q: string) => void; + setField: (f: PlexSearchField) => void; setPlaylistOpen: (id: number | null) => void; + setJumpTo: (t: PlexJumpTarget | null) => void; } const StateContext = createContext({ @@ -43,7 +52,9 @@ const StateContext = createContext({ sort: PLEX_SORT_FALLBACK, filters: EMPTY_PLEX_FILTERS, q: "", + field: "all", playlistOpen: null, + jumpTo: null, }); const ActionsContext = createContext({ setScope: () => {}, @@ -51,7 +62,9 @@ const ActionsContext = createContext({ setSort: () => {}, setFilters: () => {}, setQ: () => {}, + setField: () => {}, setPlaylistOpen: () => {}, + setJumpTo: () => {}, }); export function usePlex(): PlexState { @@ -69,6 +82,7 @@ function readPlexScope(): string { const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all"; const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}"; const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? ""; +const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField)); // Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the // direction that used to live in plexFilters.sortDir — so an existing sort preference survives the // upgrade rather than snapping back to the default. @@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) { }, [filtersRaw]); // Survives F5 (persisted), unlike the feed search — see the scope note above. const [q, setQState] = useState(readPlexQ); + const [field, setFieldState] = useState(readPlexField); const [playlistOpen, setPlaylistOpen] = useState(null); + // Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to; + // PlexBrowse consumes it into an info/show history subview then clears it. + const [jumpTo, setJumpTo] = useState(null); // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab). const markDirty = useAccountRehydrate(() => { @@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) { setSortState(readPlexSort()); setFiltersRaw(readPlexFiltersRaw()); setQState(readPlexQ()); + setFieldState(readPlexField()); }); const setScope = useCallback( @@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) { }, [markDirty], ); + const setField = useCallback( + (v: PlexSearchField) => { + markDirty(); + setFieldState(v); + setAccountRaw(LS.plexField, v); + }, + [markDirty], + ); const state = useMemo( - () => ({ scope, show, sort, filters, q, playlistOpen }), - [scope, show, sort, filters, q, playlistOpen], + () => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }), + [scope, show, sort, filters, q, field, playlistOpen, jumpTo], ); const actions = useMemo( - () => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }), - [setScope, setShow, setSort, setFilters, setQ], + () => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }), + [setScope, setShow, setSort, setFilters, setQ, setField], ); return ( diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx new file mode 100644 index 00000000..3134bc42 --- /dev/null +++ b/frontend/src/components/PlexSearchBox.tsx @@ -0,0 +1,150 @@ +import { useEffect, 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 "./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); + const [focused, setFocused] = useState(false); + const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none) + const listRef = useRef(null); + + // 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 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 ?? []).slice(0, SUGGEST_LIMIT) : []; + const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0); + + // A fresh query resets the highlight; keep it in range if the list shrank. + useEffect(() => setHi(-1), [dq, activeField, scope]); + + const pick = (i: number) => { + const c = suggestions[i]; + if (!c) return; + setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" }); + setFocused(false); + setHi(-1); + }; + + const fields = availablePlexFields(scope); + const pillLabel = t(plexFieldLabelKey(activeField)); + + return ( + 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. + 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") { + setFocused(false); + } + }} + leading={ + + {pillLabel} +