refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
|
||||
// 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);
|
||||
};
|
||||
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;
|
||||
|
||||
// Clear the typed query when the list closes.
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
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.
|
||||
if (e.target !== inputRef.current) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpenAnd(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) => (
|
||||
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
|
||||
<span className="max-w-36 truncate">{labelFor(v)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
// Keep the box's focus-the-field handler from firing when removing a chip.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => onChange(selected.filter((x) => x !== v))}
|
||||
aria-label={t("common.remove", { defaultValue: "Remove" })}
|
||||
className="hover:opacity-70"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
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.
|
||||
className="min-w-8 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
|
||||
/>
|
||||
<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="glass-menu w-full rounded-xl p-1.5 shadow-lg 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("plex.loading", { defaultValue: "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-md 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("plex.filter.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("sidebar.match")}</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user