Files
siftlode/frontend/src/components/ui/Combobox.tsx
T
peter e9c15baae0 fix(feed): a channel filter's chips name their channels from the first paint
After a reload the chips (filter panel and the row above the feed) read as
raw UC… ids until the subscription list landed. The names seen last time
are now kept per account (useChannelNames) and name the chips at once; the
list then replaces the copy, so a renamed or dropped channel heals. A
channel never named on this browser draws a placeholder bone while the
list loads; a failed list still falls back to the id.
2026-10-03 03:29:54 +02:00

359 lines
16 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 { ChevronDown } from "lucide-react";
import { Button } from "./Button";
import { MenuCheck } from "./MenuCheck";
import { PopoverItem, PopoverList, PopoverSurface } 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";
import { ChipLabelPending } from "./Skeleton";
// 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;
/** Loads the options again after `error`: a Retry beside the message, and Enter in the field. A
* failed list with no way back but closing the dialog was a dead end. */
onRetry?: () => void;
/** 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 <body>), 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<string, string>;
/** The labels are still on their way: a selected value with none yet draws a placeholder chip
* instead of its raw value (a channel filter's UC… id after a reload). */
labelsPending?: boolean;
}
export default function Combobox({
label,
options,
selected,
onChange,
multiple = true,
onOpenChange,
placeholder,
query: queryProp,
onQueryChange,
clientFilter = true,
loading = false,
error,
onRetry,
disabled = false,
emptyText,
maxVisible = 50,
renderOption,
selectedLabels,
labelsPending = false,
}: 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 knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache.current[v];
const labelFor = (v: string) => knownLabel(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 showError = error != null && filtered.length === 0;
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 (
<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",
disabled && "cursor-not-allowed opacity-50",
)}
>
{selected.map((v) => (
<DismissibleChip
key={v}
className="max-w-40"
onDismiss={() => !disabled && onChange(selected.filter((x) => x !== v))}
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
>
{labelsPending && knownLabel(v) === undefined ? (
<ChipLabelPending value={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 === "Enter" && open && showError && onRetry) {
// The focus stays in the field (the list is aria-activedescendant), so the Retry is
// reached from here, the way an option is.
e.preventDefault();
onRetry();
} 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.
/>
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div>
<PopoverSurface
popover={pop}
ariaLabel={label}
listInside
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">
{/* The listbox holds the options alone; the lines about the list sit beside it (F4). */}
<PopoverList>
{!showError &&
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)
) : (
<>
<MenuCheck kind={multiple ? "many" : "one"} on={on} />
<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>
);
})}
</PopoverList>
{showError ? (
<div className="flex items-center gap-2 px-2 py-1.5">
<p role="alert" className="flex-1 text-xs text-danger">
{error}
{onRetry && <span className="sr-only"> {t("common.retryWithEnter")}</span>}
</p>
{onRetry && (
// For the pointer: the keyboard's Retry is Enter in the field (said in the alert
// above), since the focus stays there the whole time the list is open.
<Button
variant="outline"
size="xs"
type="button"
tabIndex={-1}
aria-hidden
// Keep the focus in the field: a press that moved it out would close the list.
onMouseDown={(e) => e.preventDefault()}
onClick={onRetry}
>
{t("common.retry")}
</Button>
)}
</div>
) : 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>
) : (
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>
</PopoverSurface>
</div>
);
}