- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title and hint (a label pointed at the switch). - F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge); labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows. - F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP) refuses the swap, contract-tested. - F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>. - F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the Config tests say why while dirty (testDirty, HU/EN). - F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle, Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest. - F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips. - F15: the "+N" / "More..." open state worded as it renders.
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
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;
|
||
/** 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<string, string>;
|
||
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
|
||
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
|
||
* only once 2+ values are selected (below that, any and all are the same). */
|
||
matchMode?: "or" | "and";
|
||
onMatchModeChange?: (mode: "or" | "and") => void;
|
||
}
|
||
|
||
export default function Combobox({
|
||
label,
|
||
options,
|
||
selected,
|
||
onChange,
|
||
multiple = true,
|
||
onOpenChange,
|
||
placeholder,
|
||
query: queryProp,
|
||
onQueryChange,
|
||
clientFilter = true,
|
||
loading = false,
|
||
emptyText,
|
||
maxVisible = 50,
|
||
renderOption,
|
||
selectedLabels,
|
||
matchMode,
|
||
onMatchModeChange,
|
||
}: 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<HTMLInputElement | null>(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<Record<string, string>>({});
|
||
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) => {
|
||
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 (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 (
|
||
<div>
|
||
{/* 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. */}
|
||
<div
|
||
ref={pop.refs.setPositionReference}
|
||
onMouseDown={(e) => {
|
||
// 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 <body>).
|
||
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",
|
||
)}
|
||
>
|
||
{selected.map((v) => (
|
||
<DismissibleChip
|
||
key={v}
|
||
className="max-w-40"
|
||
onDismiss={() => onChange(selected.filter((x) => x !== v))}
|
||
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
|
||
>
|
||
{labelFor(v)}
|
||
</DismissibleChip>
|
||
))}
|
||
<InnerInput
|
||
className="min-w-8 px-1 py-0.5"
|
||
ref={setInputRef}
|
||
{...pop.getReferenceProps({
|
||
onChange: (e) => {
|
||
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" && 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" }))
|
||
: ""
|
||
}
|
||
// 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.
|
||
/>
|
||
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
|
||
</div>
|
||
|
||
<PopoverSurface
|
||
popover={pop}
|
||
ariaLabel={label}
|
||
className={`${SURFACE.menu} w-full animate-[popIn_0.16s_ease]`}
|
||
>
|
||
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
|
||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||
{loading && filtered.length === 0 ? (
|
||
<p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p>
|
||
) : filtered.length === 0 ? (
|
||
<p className="px-2 py-1.5 text-xs text-muted">
|
||
{emptyText ?? t("common.noResults", { defaultValue: "No matches" })}
|
||
</p>
|
||
) : (
|
||
<>
|
||
{visible.map((o) => {
|
||
const on = selected.includes(o.value);
|
||
return (
|
||
<PopoverItem
|
||
key={o.value}
|
||
role="option"
|
||
selected={on}
|
||
disabled={o.disabled}
|
||
onSelect={() => 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)
|
||
) : (
|
||
<>
|
||
<span
|
||
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
|
||
on ? "border-accent bg-accent text-accent-fg" : "border-border"
|
||
}`}
|
||
>
|
||
{on && <Check className="h-2.5 w-2.5" />}
|
||
</span>
|
||
<span className="flex-1 truncate">{o.label ?? o.value}</span>
|
||
{o.count != null && (
|
||
<span className="shrink-0 text-2xs tabular-nums text-muted">
|
||
{o.count}
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</PopoverItem>
|
||
);
|
||
})}
|
||
{hiddenCount > 0 && (
|
||
<p className="px-2 py-1.5 text-2xs text-muted">
|
||
{t("common.moreRefine", {
|
||
count: hiddenCount,
|
||
defaultValue: "{{count}} more — refine with search",
|
||
})}
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
|
||
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
|
||
open in the combobox's focus model. */}
|
||
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
|
||
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
|
||
<span className="text-2xs text-muted">{t("common.matchMode")}</span>
|
||
<div className="ml-auto flex gap-1">
|
||
{(["or", "and"] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
aria-pressed={matchMode === m}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => onMatchModeChange(m)}
|
||
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
|
||
matchMode === m
|
||
? "border-accent bg-accent/15 text-accent"
|
||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||
}`}
|
||
>
|
||
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</PopoverSurface>
|
||
</div>
|
||
);
|
||
}
|