Files
siftlode/frontend/src/components/ui/Combobox.tsx
T
peter 295c1db5f1 fix(design): claim audit part 1b — whole-row switches, the field-family edge, state-layer hovers, icon links, reason-disabled controls, 24/44 targets (E28)
- 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.
2026-09-25 03:58:17 +02:00

332 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}