import clsx from "clsx"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown } from "lucide-react"; import { PopoverSurface, PopoverItem } from "./AnchoredPopover"; import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { useScrollFade } from "../../lib/useScrollFade"; import { InnerInput } from "./Input"; import { DismissibleChip } from "./Chip"; import { fieldClass } from "./fieldStyles"; import { SURFACE } from "./surfaces"; // The universal combobox: a react-select / Overseerr-style "combi input" — selected values are // removable chips that live INSIDE a text field you type into, and a checklist of options opens below. // It is the ONE control for "pick value(s) from a (possibly large, possibly async) vocabulary": the E5 // Plex filters (genre / age-rating / studio, multi-select) and the collection picker (single-select, // server-searched) are both instances, and the header search typeahead can move onto it later. // // All the fiddly parts live in the AnchoredPopover `combobox` mode, NOT here: floating-ui positioning, // the paint-before-position + transform-clobber flash guards, the topmost-only Escape layer, and the // FULL combobox ARIA tree (input role=combobox + aria-expanded/controls/activedescendant, listbox // surface, option ids, virtual ↑/↓ arrow-nav that keeps the caret in the field). This component only // adds the field chrome (chips + input + chevron), the query/selection state, and the big-vocabulary // render cap. That is what makes it accessible where the old hand-rolled MultiSelectChips was not. export interface ComboboxOption { value: string; /** Display text; defaults to `value`. */ label?: string; /** A facet count shown at the row's end. */ count?: number; disabled?: boolean; } export interface ComboboxProps { /** Accessible name for the input (the section heading usually names it visibly too). */ label: string; options: readonly ComboboxOption[]; /** Selected values. Single-select carries 0 or 1. */ selected: string[]; onChange: (next: string[]) => void; /** Several values at once (chips accumulate) vs one (picking replaces + closes). Default true. */ multiple?: boolean; /** Notified when the list opens/closes — a server-searched caller gates its fetch on `open`. */ onOpenChange?: (open: boolean) => void; placeholder?: string; /** Controlled query text — pass with `onQueryChange` for a server-searched (async) vocabulary; omit * to let the combobox own the query state and filter `options` on the client. */ query?: string; onQueryChange?: (q: string) => void; /** Filter `options` by the query on the client (default). Set false when the caller already narrows * the list server-side (then `options` arrives pre-filtered). */ clientFilter?: boolean; loading?: boolean; /** The options failed to load: said in the list instead of "No matches" (an error is not an empty * result — E28 review J1). */ error?: string; /** Takes no pick (e.g. the last one is still being written, J2). Soft, like `FocusSafeButton`: the * field keeps the focus (a natively disabled one drops it to ), announced as unavailable. */ disabled?: boolean; /** Shown when there are no options to list. */ emptyText?: string; /** Cap on rendered rows for a huge vocabulary; the rest hide behind the search. Default 50. */ maxVisible?: number; /** Custom row body (icon + label + count …); defaults to a checkbox + label + count. */ renderOption?: (option: ComboboxOption, isSelected: boolean) => ReactNode; /** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed) * list — the collection picker passes the stored title here so a reloaded filter still names itself. */ selectedLabels?: Record; } export default function Combobox({ label, options, selected, onChange, multiple = true, onOpenChange, placeholder, query: queryProp, onQueryChange, clientFilter = true, loading = false, error, disabled = false, emptyText, maxVisible = 50, renderOption, selectedLabels, }: ComboboxProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); // The query is controlled when the caller drives it (async search), else owned here. const [queryState, setQueryState] = useState(""); const query = queryProp ?? queryState; const setQuery = (q: string) => { if (queryProp === undefined) setQueryState(q); onQueryChange?.(q); }; const inputRef = useRef(null); const fade = useScrollFade(); // Remember each value's display label as options flow through, so a selected chip can name itself // even after the option leaves the query-narrowed list (async single-select). Caller-supplied // `selectedLabels` win (they survive a page reload where nothing has been fetched yet). const labelCache = useRef>({}); useEffect(() => { for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label; }, [options]); const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v; const setOpenAnd = (next: boolean) => { if (next && disabled) return; setOpen(next); onOpenChange?.(next); // Closing discards the typed query: the list is for "find and pick", and a leftover filter would // hide everything worth seeing on the next open. It belongs HERE, not in an effect on `open`, // because this is the single funnel every close goes through — floating-ui's dismiss, Escape and // outside-click all arrive as onOpenChange, and the single-select commit calls it directly // (verified: `setOpen` is called nowhere else). So the clear lands in the same commit as the // close, and the controlled caller's onQueryChange fires once, from an event rather than a render. if (!next) setQuery(""); }; const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" }); // The input is the combobox reference for floating-ui AND our own focus target (the field's // click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the // node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !== // inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making // the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached // every render. const setInputRef = useCallback( (node: HTMLInputElement | null) => { pop.refs.setReference(node); inputRef.current = node; }, [pop.refs], ); // `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter // commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle // a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null) // would make Enter a no-op over a highlighted row. const { activeIndex, highlightIndex } = pop; const filtered = useMemo(() => { if (!clientFilter) return options; const q = query.trim().toLowerCase(); if (!q) return options; return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q)); }, [options, query, clientFilter]); // Options arrive count-desc (facet order), so the capped default view is the most-common values; a // footer flags how many more are reachable through the search. const visible = filtered.slice(0, maxVisible); const hiddenCount = filtered.length - visible.length; const commit = (value: string) => { if (disabled) return; if (multiple) { onChange( selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value], ); // Keep the field focused so the user can keep picking / typing; the list stays open. inputRef.current?.focus(); } else { // Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would // immediately re-open the list (now that the merged ref makes focus() actually work). onChange([value]); setOpenAnd(false); } }; return (
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. The whole box is the POSITION anchor (the panel matches its width); the input is the combobox reference. */}
{ // A press anywhere but the input (padding / a chip gap) focuses the field and opens the list — // focus drives "open", so a caret click never toggles it shut. A press on a chip's × is its // own control: it neither opens the list nor takes the focus (the × unmounts on the click, // and a focused one would drop the focus to ). if (e.target instanceof Element && e.target.closest("button")) { e.preventDefault(); return; } if (e.target !== inputRef.current) { e.preventDefault(); inputRef.current?.focus(); setOpenAnd(true); } }} className={clsx( fieldClass({ size: "free" }), "flex min-h-8 w-full cursor-text flex-wrap items-center gap-1 px-1.5 py-0.5 text-sm", disabled && "cursor-not-allowed opacity-50", )} > {selected.map((v) => ( !disabled && onChange(selected.filter((x) => x !== v))} dismissLabel={t("common.removeNamed", { name: labelFor(v) })} > {labelFor(v)} ))} { setQuery((e.target as HTMLInputElement).value); setOpenAnd(true); }, onFocus: () => setOpenAnd(true), onKeyDown: (e) => { const idx = highlightIndex ?? activeIndex; if (e.key === "Enter" && open && idx != null && visible[idx]) { e.preventDefault(); // select the highlighted option, don't submit a surrounding form if (!visible[idx].disabled) commit(visible[idx].value); } else if ( e.key === "Backspace" && !disabled && query === "" && selected.length > 0 ) { // Backspace on an empty field removes the last chip (react-select behaviour). onChange(selected.slice(0, -1)); } }, })} value={query} readOnly={disabled} aria-disabled={disabled || undefined} aria-label={label} placeholder={ selected.length === 0 ? (placeholder ?? t("common.search", { defaultValue: "Search" })) : "" } // min-w-8 (not -16): flex line-packing sizes the input at its MIN-width, so a wide 64px floor // made a single chip + the reserved input + the chevron overflow a narrow rail field — wrapping // the chevron to a second line while the (empty) input filled the first. A small floor keeps the // chip + input + chevron on one line; flex-1 still grows the input to fill whatever room is left. />
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
{error && filtered.length === 0 ? (

{error}

) : loading && filtered.length === 0 ? (

{t("common.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-lg 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("common.moreRefine", { count: hiddenCount, defaultValue: "{{count}} more — refine with search", })}

)} )}
); }