diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index de4526a1..8f980402 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, + useId, useLayoutEffect, useRef, useState, @@ -61,14 +62,29 @@ import { useFullscreenElement } from "../lib/fullscreen"; // right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a // free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and // typeahead act on the text, not the list — see ChannelTags. +// +// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input +// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips / +// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model: +// - the trigger IS a text input, so open is driven by the caller (focus/typing), NOT useClick; +// - focus STAYS on the input — the surface is NOT focus-trapped and list items are never DOM-focused; +// arrow-nav is VIRTUAL (useListNavigation `virtual:true` → the ↑/↓/Home/End handler rides on the +// input's getReferenceProps and moves an `aria-activedescendant` pointer, not real focus). +// Because `role:"combobox"` is passed to useRole, Floating UI wires the whole ARIA tree for free: the +// reference input gets role=combobox + aria-expanded/controls/autocomplete/activedescendant, the surface +// gets role=listbox, and each gets its option id (so activedescendant has a +// target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference") +// and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input +// field). The universal component wraps all of this — call sites use it, not this mode raw. // Internal to the platform; call sites pass the string literal via `mode`, they don't name the type. -type PopoverMode = "menu" | "listbox" | "hover" | "dialog"; +type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox"; export interface AnchoredPopoverOptions { open: boolean; onOpenChange: (open: boolean, event?: Event) => void; - /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */ + /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards); + * "combobox" is a typeable input + listbox where focus stays in the field (use via ). */ mode?: PopoverMode; placement?: Placement; /** Gap between trigger and panel, px. Default 4. */ @@ -99,9 +115,14 @@ type AnchoredPopover = ReturnType; export function useAnchoredPopover(opts: AnchoredPopoverOptions) { const mode = opts.mode ?? "menu"; const isHover = mode === "hover"; + const isCombobox = mode === "combobox"; // Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds // arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves. const isNav = mode === "menu" || mode === "listbox"; + // Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND + // the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept + // out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here. + const roves = isNav || isCombobox; // null when not inside a — the hook still works, just without nested-layer awareness. const nodeId = useFloatingNodeId(); @@ -143,6 +164,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { }), ] : []), + // A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner + // scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input + // field box (the caller mounts it via setPositionReference), not the bare input. + ...(isCombobox + ? [ + size({ + padding: 8, + apply({ rects, elements }) { + elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`; + }, + }), + ] + : []), hide({ padding: 8 }), ], }); @@ -156,7 +190,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { } }, [opts.open]); - const click = useClick(context, { enabled: !isHover }); + // A combobox opens from the field's own focus/typing (wired by ), never a click-to-toggle — + // a click on the input must place the caret, not close the just-opened list. + const click = useClick(context, { enabled: !isHover && !isCombobox }); const hover = useHover(context, { enabled: isHover, move: false, @@ -170,9 +206,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside- // press stays with floating-ui (tree-aware). const dismiss = useDismiss(context, { escapeKey: false }); - const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") }); + // `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input + // → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav), + // the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the + // `listbox` aria-role internally.) So sets NO aria-* by hand. + const role = useRole(context, { + role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"), + }); const listNav = useListNavigation(context, { - enabled: isNav, + enabled: roves, + // Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an + // `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the + // input). A menu/listbox uses real roving focus instead. + virtual: isCombobox, listRef: elementsRef, activeIndex, // Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from @@ -188,12 +234,15 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { // `isListIndexDisabled`.) No explicit index list needed. loop: true, // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, - // which strands focus on the menu container — arrows then can't enter the roving list at all). - focusItemOnOpen: true, - // A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely - // passes over it (the library default) — that blurs the input mid-typing and mis-routes the next - // Enter to the hovered item. Turned off together with typeahead for such menus. - focusItemOnHover: !opts.hostsInput, + // which strands focus on the menu container — arrows then can't enter the roving list at all). A + // combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first + // option; auto-highlighting item 0 on open would fight the typed query. + focusItemOnOpen: isCombobox ? false : true, + // A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when + // the pointer merely passes over it (the library default) — that blurs the input mid-typing and + // mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex + // instead (see PopoverItem), so the pointer still highlights without stealing the caret. + focusItemOnHover: isCombobox ? false : !opts.hostsInput, }); const typeahead = useTypeahead(context, { enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput, @@ -222,6 +271,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { nodeId, mode, isNav, + isCombobox, + roves, open: opts.open, selectedIndex: opts.selectedIndex ?? null, refs, @@ -245,6 +296,9 @@ interface PopoverListContextValue { highlightIndex: number | null; setHoverIndex: (index: number | null) => void; getItemProps: AnchoredPopover["getItemProps"]; + /** Combobox surface: items are navigated VIRTUALLY (aria-activedescendant), so a never + * takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */ + virtual: boolean; } const PopoverListContext = createContext(null); @@ -302,6 +356,12 @@ export function PopoverItem({ label?: string; } & Omit, "onSelect" | "role" | "children" | "className">) { const ctx = useContext(PopoverListContext); + const virtual = ctx?.virtual ?? false; + // A virtual (combobox) option needs a STABLE DOM id from first render: the input's aria-activedescendant + // is set to the active option's `.id` when the arrow handler reads it, so the id must already be there + // (Floating UI's own on-activation id assignment races the re-render and lands empty through our + // indirection). A menu/listbox item uses roving DOM focus instead and needs no id. + const generatedId = useId(); const textLabel = label ?? (typeof children === "string" ? children : undefined); const { ref, index } = useListItem({ label: textLabel }); // Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight @@ -309,12 +369,12 @@ export function PopoverItem({ const focusActive = ctx?.activeIndex === index; const visualActive = ctx?.highlightIndex === index; - const ariaProps = - role === "menuitemradio" || role === "menuitemcheckbox" - ? { role, "aria-checked": !!checked } - : role === "option" - ? { role, "aria-selected": !!selected } - : { role }; + // The checked/selected state is rendered as an EXPLICIT attribute after {...handlers} below (so it + // wins): for a combobox `role:"combobox"` makes getItemProps emit its own `aria-selected` off the + // single selectedIndex, which is always false for a MULTI-select — our per-value truth must override. + const ariaChecked = + role === "menuitemradio" || role === "menuitemcheckbox" ? !!checked : undefined; + const ariaSelected = role === "option" ? !!selected : undefined; // Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which // composes handlers rather than dropping them — spreading `{...rest}` separately would let the @@ -332,19 +392,37 @@ export function PopoverItem({ rest.onMouseMove?.(e); if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index); }; + // In a combobox, clicking an option must NOT move DOM focus off the input (that would close the list + // and drop the caret) — preventDefault the mousedown so focus stays put; the onClick still selects. + const mergedMouseDown = (e: React.MouseEvent) => { + rest.onMouseDown?.(e); + if (virtual) e.preventDefault(); + }; const handlers = ctx - ? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove }) - : { ...rest, onClick: mergedClick }; + ? ctx.getItemProps({ + ...rest, + onClick: mergedClick, + onMouseMove: mergedMouseMove, + onMouseDown: mergedMouseDown, + }) + : { ...rest, onClick: mergedClick, onMouseDown: mergedMouseDown }; return ( + + ))} + { + setQuery((e.target as HTMLInputElement).value); + setOpenAnd(true); + }, + onFocus: () => setOpenAnd(true), + onKeyDown: (e) => { + if (e.key === "Enter" && open && activeIndex != null && visible[activeIndex]) { + e.preventDefault(); // select the highlighted option, don't submit a surrounding form + if (!visible[activeIndex].disabled) commit(visible[activeIndex].value); + } else if (e.key === "Backspace" && query === "" && selected.length > 0) { + // Backspace on an empty field removes the last chip (react-select behaviour). + onChange(selected.slice(0, -1)); + } + }, + })} + value={query} + aria-label={label} + placeholder={ + selected.length === 0 + ? (placeholder ?? t("common.search", { defaultValue: "Search" })) + : "" + } + className="min-w-16 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60" + /> + + + + + {/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */} +
+ {loading && filtered.length === 0 ? ( +

+ {t("plex.loading", { defaultValue: "Loading…" })} +

+ ) : filtered.length === 0 ? ( +

+ {emptyText ?? t("common.noResults", { defaultValue: "No matches" })} +

+ ) : ( + <> + {visible.map((o) => { + const on = selected.includes(o.value); + return ( + commit(o.value)} + label={o.label ?? o.value} + className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${ + on ? "text-fg" : "text-muted" + }`} + > + {renderOption ? ( + renderOption(o, on) + ) : ( + <> + + {on && } + + {o.label ?? o.value} + {o.count != null && ( + + {o.count} + + )} + + )} + + ); + })} + {hiddenCount > 0 && ( +

+ {t("plex.filter.moreRefine", { + count: hiddenCount, + defaultValue: "{{count}} more — refine with search", + })} +

+ )} + + )} +
+
+ + ); +} diff --git a/frontend/src/components/MultiSelectChips.dom.test.tsx b/frontend/src/components/MultiSelectChips.dom.test.tsx deleted file mode 100644 index 5f5e1890..00000000 --- a/frontend/src/components/MultiSelectChips.dom.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// @vitest-environment jsdom -// -// E5 S7: the combi-input (chips + typed search field) multi-value filter. Covers the selected-chip -// removal, opening the list by FOCUSING the field (not a button anymore), the search filter, and -// toggling an option on. i18n is key-passthrough; the popover is floating-ui based (ActionMenu's test -// recipe: stub the two observers autoUpdate wires, wrap in a FloatingTree, and query the hidden surface -// with `{ hidden: true }` since jsdom never positions it). -import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; -import { FloatingTree } from "@floating-ui/react"; - -beforeAll(() => { - class Stub { - observe() {} - unobserve() {} - disconnect() {} - takeRecords() { - return []; - } - } - globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; - globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; -}); - -vi.mock("react-i18next", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; -}); - -import MultiSelectChips from "./MultiSelectChips"; - -afterEach(cleanup); - -const OPTIONS = [ - { value: "Drama", count: 120 }, - { value: "Action", count: 90 }, - { value: "Comedy", count: 60 }, -]; - -function wrap(ui: React.ReactElement) { - return render({ui}); -} - -describe("MultiSelectChips", () => { - it("renders selected values as removable chips and removes on ✕", () => { - const onChange = vi.fn(); - wrap( - , - ); - expect(screen.getByText("Drama")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "common.remove" })); - expect(onChange).toHaveBeenCalledWith([]); // Drama removed - }); - - it("keeps the option list unmounted until the field is focused", () => { - wrap(); - expect(screen.queryByRole("option", { hidden: true })).toBeNull(); - fireEvent.focus(screen.getByRole("textbox")); - expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3); - }); - - it("filters the options by the typed query", () => { - wrap(); - const field = screen.getByRole("textbox"); - fireEvent.focus(field); - fireEvent.change(field, { target: { value: "com" } }); - const items = screen.getAllByRole("option", { hidden: true }); - expect(items.length).toBe(1); - expect(items[0]?.textContent).toContain("Comedy"); - }); - - it("toggles an option on with its value added to the selection", () => { - const onChange = vi.fn(); - wrap( - , - ); - fireEvent.focus(screen.getByRole("textbox")); - fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on - expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]); - }); - - it("removes the last chip on Backspace in an empty field", () => { - const onChange = vi.fn(); - wrap( - , - ); - fireEvent.keyDown(screen.getByRole("textbox"), { key: "Backspace" }); - expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped - }); - - it("caps the rendered rows for a large vocabulary and shows a refine hint", () => { - // A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer. - const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i })); - wrap(); - fireEvent.focus(screen.getByRole("textbox")); - expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50); - expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search - }); -}); diff --git a/frontend/src/components/MultiSelectChips.tsx b/frontend/src/components/MultiSelectChips.tsx deleted file mode 100644 index 343794a3..00000000 --- a/frontend/src/components/MultiSelectChips.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Check, ChevronDown, X } from "lucide-react"; -import { - autoUpdate, - flip, - FloatingPortal, - offset, - shift, - size, - useDismiss, - useFloating, - useInteractions, - useRole, -} from "@floating-ui/react"; -import { useScrollFade } from "../lib/useScrollFade"; - -// A "combi input" multi-value filter control (E5 S7): the selected values are removable chips that -// live INSIDE a text field you can type into (the text filters the vocabulary), and a checklist of -// options opens below on focus — a react-select / Overseerr-style combobox. -// -// Built on floating-ui PRIMITIVES directly, NOT the AnchoredPopover platform: that platform is menu- -// shaped (useClick to toggle + a FloatingFocusManager + focusItemOnOpen that pull focus onto the list -// the moment it opens), which for a combobox blurs the field so you can't type until a second click. -// Here focus simply stays in the field (no focus manager); open is driven by the field's focus, and -// useDismiss closes on an outside press / Escape. - -export interface MultiSelectOption { - value: string; - count?: number; -} - -export default function MultiSelectChips({ - label, - options, - selected, - onChange, - searchPlaceholder, -}: { - /** Accessible name for the field + listbox (e.g. "Genre"). The visible section title names it too. */ - label: string; - options: readonly MultiSelectOption[]; - selected: string[]; - onChange: (next: string[]) => void; - /** Optional placeholder for the empty field; defaults to a plain "Search". */ - searchPlaceholder?: string; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const inputRef = useRef(null); - const fade = useScrollFade(); - - const { refs, floatingStyles, context, isPositioned } = useFloating({ - open, - onOpenChange: setOpen, - placement: "bottom-start", - whileElementsMounted: autoUpdate, - middleware: [ - offset(4), - flip({ padding: 8 }), - shift({ padding: 8 }), - size({ - padding: 8, - apply({ rects, elements }) { - // Match the field width (with a sane minimum for a very narrow rail); the inner scroll box - // owns the height cap (max-h-64). - elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`; - }, - }), - ], - }); - const dismiss = useDismiss(context); // outside-press + Escape close the list - const role = useRole(context, { role: "listbox" }); - const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, role]); - - // Clear the typed query when the list closes. - useEffect(() => { - if (!open) setQuery(""); - }, [open]); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return q ? options.filter((o) => o.value.toLowerCase().includes(q)) : options; - }, [options, query]); - - // A large vocabulary (studios can run to hundreds) must not render every option as a DOM node — cap - // the rendered rows and let search narrow to the rest. Options arrive count-desc, so the default view - // is the most-common values; a footer flags how many more are hidden behind the search. - const MAX_VISIBLE = 50; - const visible = filtered.slice(0, MAX_VISIBLE); - const hiddenCount = filtered.length - visible.length; - - const toggle = (v: string) => - onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]); - - return ( -
- {/* The field: removable chips + a free-text input + a chevron, styled as one input box. */} -
{ - // A press anywhere but the input (padding / a chip gap) focuses the field and opens the - // list — the field's focus is the single source of "open", so a caret click never toggles - // it shut. - if (e.target !== inputRef.current) { - e.preventDefault(); - inputRef.current?.focus(); - setOpen(true); - } - }, - })} - className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30" - > - {selected.map((v) => ( - - {v} - - - ))} - { - setQuery(e.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - onKeyDown={(e) => { - if (e.key === "Escape") { - setOpen(false); - return; - } - // Backspace on an empty field removes the last chip (react-select behaviour). - if (e.key === "Backspace" && query === "" && selected.length > 0) { - onChange(selected.slice(0, -1)); - } - }} - placeholder={ - selected.length === 0 - ? (searchPlaceholder ?? t("common.search", { defaultValue: "Search" })) - : "" - } - aria-label={label} - className="min-w-16 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60" - /> - -
- - {open && ( - - - - )} -
- ); -} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index e257bf44..acd1ecb0 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,20 +1,18 @@ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { plexLang } from "../i18n"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Layers, ListMusic, Plus, Search, SlidersHorizontal, X } from "lucide-react"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, plexFilterCount, type PlexFilters } from "../lib/api"; import { type PanelLayout } from "../lib/panelLayout"; import { type MediaSourceKind } from "../lib/mediaSource"; import { useDebounced } from "../lib/useDebounced"; -import { useScrollFade } from "../lib/useScrollFade"; import FilterRail from "./FilterRail"; import { type PanelItem } from "./PanelGroups"; import SegmentedToggle from "./SegmentedToggle"; import FilterChipGroup from "./FilterChipGroup"; -import MultiSelectChips from "./MultiSelectChips"; +import Combobox, { type ComboboxOption } from "./Combobox"; import { usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; @@ -205,15 +203,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl { id: "collection", title: t("plex.filter.collection"), - body: filters.collection ? ( -
- patch({ collection: null, collectionTitle: null })} - /> -
- ) : ( + body: ( patch({ collection: id, collectionTitle: title })} /> ), @@ -248,7 +241,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl /> )} - void }) { +// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the +// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The +// generic owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on +// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing +// the chip drops it. +function CollectionPicker({ + value, + title, + onPick, +}: { + value: string | null | undefined; + title: string | null | undefined; + onPick: (id: string | null, title: string | null) => void; +}) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const searchDeb = useDebounced(search.trim(), 300); - const inputRef = useRef(null); - const fade = useScrollFade(); const q = useQuery({ queryKey: qk.plexCollections("union", searchDeb), queryFn: () => api.plexCollections(undefined, searchDeb || undefined), enabled: open, // only fetch while the picker is open }); - const collections = q.data?.collections ?? []; - // Search narrows server-side; cap the client render so a large union (hundreds) stays light. - const visibleCollections = collections.slice(0, 50); - const hiddenCollections = collections.length - visibleCollections.length; - const pop = useAnchoredPopover({ - open, - onOpenChange: setOpen, - mode: "menu", - placement: "bottom-start", - hostsInput: true, - }); - useEffect(() => { - if (open) inputRef.current?.focus(); - else setSearch(""); - }, [open]); + const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({ + value: c.id, + label: c.title, + count: c.child_count ?? undefined, + })); return ( - <> - - -
- - setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") return; // let the popover dismiss handle it - e.stopPropagation(); // keep text editing out of the surface's list-nav/typeahead - }} - placeholder={t("plex.filter.collectionSearch")} - className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" - /> -
-
- {collections.length === 0 ? ( -

{t("plex.filter.collectionEmpty")}

- ) : ( - <> - {visibleCollections.map((c) => ( - { - onPick(c.id, c.title); - setOpen(false); - }} - label={c.title} - className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted outline-hidden transition" - > - - {c.title} - {c.child_count != null && ( - - {c.child_count} - - )} - - ))} - {hiddenCollections > 0 && ( -

- {t("plex.filter.moreRefine", { count: hiddenCollections })} -

- )} - - )} -
-
- + { + const id = next[0] ?? null; + const picked = options.find((o) => o.value === id); + onPick(id, id ? (picked?.label ?? title ?? id) : null); + }} + /> ); }