feat(e5): first-class combobox platform mode + universal Combobox (A11Y-1)
The E5 filter pickers were a hand-rolled floating-ui combobox (MultiSelectChips) with no combobox ARIA, no arrow-nav, and options in a body-portal — keyboard/SR users could not operate it, and the bare role=listbox/option wiring actively misled screen readers (deep-review A11Y-1, CONFIRMED 3/3 medium). Rather than one-off-hack the a11y, add combobox as a first-class mode of the AnchoredPopover platform and rebuild the pickers on a reusable component. Platform (AnchoredPopover): - new mode "combobox": reuses the whole positioning core (offset/flip/shift/size/ hide, isPositioned + transform-clobber flash guards, FloatingPortal + fullscreen re-parent, topmost-only Escape layer). Differs only in the focus model — no useClick (open is field-driven), useRole role="combobox" so Floating UI wires the full ARIA tree (input role=combobox + aria-expanded/controls/autocomplete/ activedescendant, listbox surface, option ids), and useListNavigation virtual:true so ↑/↓ move an aria-activedescendant pointer while DOM focus STAYS on the input. - PopoverSurface: combobox surfaces host the item registry but are NOT focus-trapped. - PopoverItem: virtual mode carries a stable id (the activedescendant target), never takes roving DOM focus, keeps the input focused on click (mousedown preventDefault), and its checked/selected truth wins over getItemProps' single-select guess. Component (Combobox.tsx, new): the universal react-select/Overseerr combi-input — chips-in-field + typed search + checklist. Supports multi/single select, client OR server (async) filtering, custom rows, the big-vocabulary render cap + refine footer, and a selected-label cache so a server-narrowed single-select still names its chip. Rollout: MultiSelectChips (genre/age-rating/studio) → Combobox; the CollectionPicker "+Add" popover → a single-select async Combobox (also fixes its arrow-keys-eaten story), unifying the two filter idioms. PlexSearchBox is a documented future consumer. Gate green (tsc, vitest 351, eslint, prettier, knip). Browser E2E on localdev: role=combobox confirmed on both pickers; ↑/↓ moves aria-activedescendant with focus staying on the input; Enter + click select; multi-select chips accumulate; Backspace removes the last chip; async collection server-search picks + closes. No backend change.
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -61,14 +62,29 @@ import { useFullscreenElement } from "../lib/fullscreen";
|
||||
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
|
||||
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
|
||||
// typeahead act on the text, not the list — see ChannelTags.
|
||||
//
|
||||
// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input
|
||||
// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips /
|
||||
// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model:
|
||||
// - the trigger IS a text input, so open is driven by the caller (focus/typing), NOT useClick;
|
||||
// - focus STAYS on the input — the surface is NOT focus-trapped and list items are never DOM-focused;
|
||||
// arrow-nav is VIRTUAL (useListNavigation `virtual:true` → the ↑/↓/Home/End handler rides on the
|
||||
// input's getReferenceProps and moves an `aria-activedescendant` pointer, not real focus).
|
||||
// Because `role:"combobox"` is passed to useRole, Floating UI wires the whole ARIA tree for free: the
|
||||
// reference input gets role=combobox + aria-expanded/controls/autocomplete/activedescendant, the surface
|
||||
// gets role=listbox, and each <PopoverItem role="option"> gets its option id (so activedescendant has a
|
||||
// target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference")
|
||||
// and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input
|
||||
// field). The universal <Combobox> component wraps all of this — call sites use it, not this mode raw.
|
||||
|
||||
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||
mode?: PopoverMode;
|
||||
placement?: Placement;
|
||||
/** Gap between trigger and panel, px. Default 4. */
|
||||
@@ -99,9 +115,14 @@ type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
const isCombobox = mode === "combobox";
|
||||
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
|
||||
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
|
||||
const isNav = mode === "menu" || mode === "listbox";
|
||||
// Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND
|
||||
// the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept
|
||||
// out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here.
|
||||
const roves = isNav || isCombobox;
|
||||
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
|
||||
const nodeId = useFloatingNodeId();
|
||||
|
||||
@@ -143,6 +164,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner
|
||||
// scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input
|
||||
// field box (the caller mounts it via setPositionReference), not the bare input.
|
||||
...(isCombobox
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`;
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
hide({ padding: 8 }),
|
||||
],
|
||||
});
|
||||
@@ -156,7 +190,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
}
|
||||
}, [opts.open]);
|
||||
|
||||
const click = useClick(context, { enabled: !isHover });
|
||||
// A combobox opens from the field's own focus/typing (wired by <Combobox>), never a click-to-toggle —
|
||||
// a click on the input must place the caret, not close the just-opened list.
|
||||
const click = useClick(context, { enabled: !isHover && !isCombobox });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover,
|
||||
move: false,
|
||||
@@ -170,9 +206,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, { escapeKey: false });
|
||||
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
|
||||
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
|
||||
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
|
||||
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
|
||||
// `listbox` aria-role internally.) So <Combobox> sets NO aria-* by hand.
|
||||
const role = useRole(context, {
|
||||
role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"),
|
||||
});
|
||||
const listNav = useListNavigation(context, {
|
||||
enabled: isNav,
|
||||
enabled: roves,
|
||||
// Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an
|
||||
// `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the
|
||||
// input). A menu/listbox uses real roving focus instead.
|
||||
virtual: isCombobox,
|
||||
listRef: elementsRef,
|
||||
activeIndex,
|
||||
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from
|
||||
@@ -188,12 +234,15 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// `isListIndexDisabled`.) No explicit index list needed.
|
||||
loop: true,
|
||||
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all).
|
||||
focusItemOnOpen: true,
|
||||
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
|
||||
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
|
||||
// Enter to the hovered item. Turned off together with typeahead for such menus.
|
||||
focusItemOnHover: !opts.hostsInput,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all). A
|
||||
// combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first
|
||||
// option; auto-highlighting item 0 on open would fight the typed query.
|
||||
focusItemOnOpen: isCombobox ? false : true,
|
||||
// A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when
|
||||
// the pointer merely passes over it (the library default) — that blurs the input mid-typing and
|
||||
// mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex
|
||||
// instead (see PopoverItem), so the pointer still highlights without stealing the caret.
|
||||
focusItemOnHover: isCombobox ? false : !opts.hostsInput,
|
||||
});
|
||||
const typeahead = useTypeahead(context, {
|
||||
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
|
||||
@@ -222,6 +271,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
nodeId,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
open: opts.open,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
refs,
|
||||
@@ -245,6 +296,9 @@ interface PopoverListContextValue {
|
||||
highlightIndex: number | null;
|
||||
setHoverIndex: (index: number | null) => void;
|
||||
getItemProps: AnchoredPopover["getItemProps"];
|
||||
/** Combobox surface: items are navigated VIRTUALLY (aria-activedescendant), so a <PopoverItem> never
|
||||
* takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */
|
||||
virtual: boolean;
|
||||
}
|
||||
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
|
||||
|
||||
@@ -302,6 +356,12 @@ export function PopoverItem({
|
||||
label?: string;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
|
||||
const ctx = useContext(PopoverListContext);
|
||||
const virtual = ctx?.virtual ?? false;
|
||||
// A virtual (combobox) option needs a STABLE DOM id from first render: the input's aria-activedescendant
|
||||
// is set to the active option's `.id` when the arrow handler reads it, so the id must already be there
|
||||
// (Floating UI's own on-activation id assignment races the re-render and lands empty through our
|
||||
// indirection). A menu/listbox item uses roving DOM focus instead and needs no id.
|
||||
const generatedId = useId();
|
||||
const textLabel = label ?? (typeof children === "string" ? children : undefined);
|
||||
const { ref, index } = useListItem({ label: textLabel });
|
||||
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight
|
||||
@@ -309,12 +369,12 @@ export function PopoverItem({
|
||||
const focusActive = ctx?.activeIndex === index;
|
||||
const visualActive = ctx?.highlightIndex === index;
|
||||
|
||||
const ariaProps =
|
||||
role === "menuitemradio" || role === "menuitemcheckbox"
|
||||
? { role, "aria-checked": !!checked }
|
||||
: role === "option"
|
||||
? { role, "aria-selected": !!selected }
|
||||
: { role };
|
||||
// The checked/selected state is rendered as an EXPLICIT attribute after {...handlers} below (so it
|
||||
// wins): for a combobox `role:"combobox"` makes getItemProps emit its own `aria-selected` off the
|
||||
// single selectedIndex, which is always false for a MULTI-select — our per-value truth must override.
|
||||
const ariaChecked =
|
||||
role === "menuitemradio" || role === "menuitemcheckbox" ? !!checked : undefined;
|
||||
const ariaSelected = role === "option" ? !!selected : undefined;
|
||||
|
||||
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
|
||||
// composes handlers rather than dropping them — spreading `{...rest}` separately would let the
|
||||
@@ -332,19 +392,37 @@ export function PopoverItem({
|
||||
rest.onMouseMove?.(e);
|
||||
if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index);
|
||||
};
|
||||
// In a combobox, clicking an option must NOT move DOM focus off the input (that would close the list
|
||||
// and drop the caret) — preventDefault the mousedown so focus stays put; the onClick still selects.
|
||||
const mergedMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
rest.onMouseDown?.(e);
|
||||
if (virtual) e.preventDefault();
|
||||
};
|
||||
const handlers = ctx
|
||||
? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove })
|
||||
: { ...rest, onClick: mergedClick };
|
||||
? ctx.getItemProps({
|
||||
...rest,
|
||||
onClick: mergedClick,
|
||||
onMouseMove: mergedMouseMove,
|
||||
onMouseDown: mergedMouseDown,
|
||||
})
|
||||
: { ...rest, onClick: mergedClick, onMouseDown: mergedMouseDown };
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...ariaProps}
|
||||
role={role}
|
||||
{...handlers}
|
||||
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
|
||||
// and disabled/className/data-active are the platform's to set.
|
||||
tabIndex={focusActive ? 0 : -1}
|
||||
// A virtual option carries its own stable id (the aria-activedescendant target); it wins over any
|
||||
// id Floating UI's getItemProps would inject on activation.
|
||||
id={virtual ? generatedId : undefined}
|
||||
// These win over anything in `handlers` — the roving tabindex must not be a caller override, the
|
||||
// checked/selected truth must beat getItemProps' single-select guess, and disabled/className/
|
||||
// data-active are the platform's to set. A virtual (combobox) item never holds DOM focus, so its
|
||||
// tabindex is always -1; navigation is by aria-activedescendant.
|
||||
aria-checked={ariaChecked}
|
||||
aria-selected={ariaSelected}
|
||||
tabIndex={virtual ? -1 : focusActive ? 0 : -1}
|
||||
data-active={visualActive ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
className={`outline-hidden ${
|
||||
@@ -409,6 +487,8 @@ function PopoverSurfaceInner({
|
||||
isPositioned,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
selectedIndex,
|
||||
activeIndex,
|
||||
highlightIndex,
|
||||
@@ -441,9 +521,10 @@ function PopoverSurfaceInner({
|
||||
if (ariaLabel) delete floatingProps["aria-labelledby"];
|
||||
|
||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||
// Everything but a passive hover card manages focus by default (trap into the surface, return to
|
||||
// the trigger on close).
|
||||
const managed = focusManaged ?? mode !== "hover";
|
||||
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
|
||||
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
|
||||
// is focus-managed.
|
||||
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
@@ -477,11 +558,11 @@ function PopoverSurfaceInner({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
|
||||
// <PopoverItem>s can self-register and rove.
|
||||
const positioned = isNav ? (
|
||||
// Menu/listbox AND combobox surfaces host the item registry (FloatingList) + active-state context so
|
||||
// nested <PopoverItem>s can self-register and rove/aria-activedescendant.
|
||||
const positioned = roves ? (
|
||||
<PopoverListContext.Provider
|
||||
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps }}
|
||||
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps, virtual: isCombobox }}
|
||||
>
|
||||
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
|
||||
{surface}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The universal combobox (chips + typed search field over the AnchoredPopover `combobox` mode). Covers
|
||||
// chip removal, open-on-focus, client filter, toggling, Backspace-removes-last, the big-vocabulary cap,
|
||||
// single-select, and the combobox ARIA the old hand-rolled control lacked (role=combobox, aria-expanded,
|
||||
// and a virtual aria-activedescendant that keeps DOM focus on the input). i18n is key-passthrough; the
|
||||
// floating-ui recipe (ActionMenu's): stub the two observers autoUpdate wires, wrap in a FloatingTree,
|
||||
// and query the hidden surface with `{ hidden: true }` since jsdom never positions it.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import Combobox from "./Combobox";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "Drama", count: 120 },
|
||||
{ value: "Action", count: 90 },
|
||||
{ value: "Comedy", count: 60 },
|
||||
];
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(<FloatingTree>{ui}</FloatingTree>);
|
||||
}
|
||||
|
||||
describe("Combobox", () => {
|
||||
it("renders selected values as removable chips and removes on ✕", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
expect(screen.getByText("Drama")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
|
||||
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
|
||||
});
|
||||
|
||||
it("exposes combobox ARIA: role=combobox, aria-expanded toggling, aria-controls when open", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(input.getAttribute("aria-controls")).toBeNull();
|
||||
fireEvent.focus(input);
|
||||
expect(input.getAttribute("aria-expanded")).toBe("true");
|
||||
// aria-controls now points at the listbox surface.
|
||||
const controls = input.getAttribute("aria-controls");
|
||||
expect(controls).toBeTruthy();
|
||||
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
|
||||
});
|
||||
|
||||
it("keeps the option list unmounted until the field is focused", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3);
|
||||
});
|
||||
|
||||
it("filters the options by the typed query", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const field = screen.getByRole("combobox");
|
||||
fireEvent.focus(field);
|
||||
fireEvent.change(field, { target: { value: "com" } });
|
||||
const items = screen.getAllByRole("option", { hidden: true });
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0]?.textContent).toContain("Comedy");
|
||||
});
|
||||
|
||||
it("marks a selected option aria-selected and toggles another on", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
const drama = screen
|
||||
.getAllByRole("option", { hidden: true })
|
||||
.find((el) => el.textContent?.includes("Drama"));
|
||||
expect(drama?.getAttribute("aria-selected")).toBe("true");
|
||||
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
|
||||
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
|
||||
});
|
||||
|
||||
it("moves an aria-activedescendant on ArrowDown while DOM focus stays on the input", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
// Virtual navigation: the input keeps DOM focus, the active option is referenced by id.
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.getAttribute("aria-activedescendant")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes the last chip on Backspace in an empty field", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Genre"
|
||||
options={OPTIONS}
|
||||
selected={["Drama", "Action"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Backspace" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped
|
||||
});
|
||||
|
||||
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
|
||||
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
|
||||
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
|
||||
wrap(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
|
||||
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
||||
});
|
||||
|
||||
it("single-select: picking replaces the value and closes the list", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
options={OPTIONS}
|
||||
selected={["Drama"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
fireEvent.click(screen.getByText("Action"));
|
||||
expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended
|
||||
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { 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>;
|
||||
}
|
||||
|
||||
export default function Combobox({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
multiple = true,
|
||||
onOpenChange,
|
||||
placeholder,
|
||||
query: queryProp,
|
||||
onQueryChange,
|
||||
clientFilter = true,
|
||||
loading = 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<HTMLInputElement>(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" });
|
||||
const { activeIndex } = 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],
|
||||
);
|
||||
} else {
|
||||
onChange([value]);
|
||||
setOpenAnd(false);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
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-xs">
|
||||
<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={pop.refs.setReference}
|
||||
{...pop.getReferenceProps({
|
||||
onChange: (e) => {
|
||||
setQuery((e.target as HTMLInputElement).value);
|
||||
setOpenAnd(true);
|
||||
},
|
||||
onFocus: () => setOpenAnd(true),
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Enter" && open && activeIndex != null && visible[activeIndex]) {
|
||||
e.preventDefault(); // select the highlighted option, don't submit a surrounding form
|
||||
if (!visible[activeIndex].disabled) commit(visible[activeIndex].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" }))
|
||||
: ""
|
||||
}
|
||||
className="min-w-16 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>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E5 S7: the combi-input (chips + typed search field) multi-value filter. Covers the selected-chip
|
||||
// removal, opening the list by FOCUSING the field (not a button anymore), the search filter, and
|
||||
// toggling an option on. i18n is key-passthrough; the popover is floating-ui based (ActionMenu's test
|
||||
// recipe: stub the two observers autoUpdate wires, wrap in a FloatingTree, and query the hidden surface
|
||||
// with `{ hidden: true }` since jsdom never positions it).
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import MultiSelectChips from "./MultiSelectChips";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "Drama", count: 120 },
|
||||
{ value: "Action", count: 90 },
|
||||
{ value: "Comedy", count: 60 },
|
||||
];
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(<FloatingTree>{ui}</FloatingTree>);
|
||||
}
|
||||
|
||||
describe("MultiSelectChips", () => {
|
||||
it("renders selected values as removable chips and removes on ✕", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
|
||||
);
|
||||
expect(screen.getByText("Drama")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
|
||||
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
|
||||
});
|
||||
|
||||
it("keeps the option list unmounted until the field is focused", () => {
|
||||
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
|
||||
fireEvent.focus(screen.getByRole("textbox"));
|
||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3);
|
||||
});
|
||||
|
||||
it("filters the options by the typed query", () => {
|
||||
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const field = screen.getByRole("textbox");
|
||||
fireEvent.focus(field);
|
||||
fireEvent.change(field, { target: { value: "com" } });
|
||||
const items = screen.getAllByRole("option", { hidden: true });
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0]?.textContent).toContain("Comedy");
|
||||
});
|
||||
|
||||
it("toggles an option on with its value added to the selection", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("textbox"));
|
||||
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
|
||||
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
|
||||
});
|
||||
|
||||
it("removes the last chip on Backspace in an empty field", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<MultiSelectChips
|
||||
label="Genre"
|
||||
options={OPTIONS}
|
||||
selected={["Drama", "Action"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Backspace" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped
|
||||
});
|
||||
|
||||
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
|
||||
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
|
||||
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
|
||||
wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.focus(screen.getByRole("textbox"));
|
||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
|
||||
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
// A "combi input" multi-value filter control (E5 S7): the selected values are removable chips that
|
||||
// live INSIDE a text field you can type into (the text filters the vocabulary), and a checklist of
|
||||
// options opens below on focus — a react-select / Overseerr-style combobox.
|
||||
//
|
||||
// Built on floating-ui PRIMITIVES directly, NOT the AnchoredPopover platform: that platform is menu-
|
||||
// shaped (useClick to toggle + a FloatingFocusManager + focusItemOnOpen that pull focus onto the list
|
||||
// the moment it opens), which for a combobox blurs the field so you can't type until a second click.
|
||||
// Here focus simply stays in the field (no focus manager); open is driven by the field's focus, and
|
||||
// useDismiss closes on an outside press / Escape.
|
||||
|
||||
export interface MultiSelectOption {
|
||||
value: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export default function MultiSelectChips({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
searchPlaceholder,
|
||||
}: {
|
||||
/** Accessible name for the field + listbox (e.g. "Genre"). The visible section title names it too. */
|
||||
label: string;
|
||||
options: readonly MultiSelectOption[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
/** Optional placeholder for the empty field; defaults to a plain "Search". */
|
||||
searchPlaceholder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fade = useScrollFade();
|
||||
|
||||
const { refs, floatingStyles, context, isPositioned } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: "bottom-start",
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(4),
|
||||
flip({ padding: 8 }),
|
||||
shift({ padding: 8 }),
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ rects, elements }) {
|
||||
// Match the field width (with a sane minimum for a very narrow rail); the inner scroll box
|
||||
// owns the height cap (max-h-64).
|
||||
elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const dismiss = useDismiss(context); // outside-press + Escape close the list
|
||||
const role = useRole(context, { role: "listbox" });
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, role]);
|
||||
|
||||
// Clear the typed query when the list closes.
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? options.filter((o) => o.value.toLowerCase().includes(q)) : options;
|
||||
}, [options, query]);
|
||||
|
||||
// A large vocabulary (studios can run to hundreds) must not render every option as a DOM node — cap
|
||||
// the rendered rows and let search narrow to the rest. Options arrive count-desc, so the default view
|
||||
// is the most-common values; a footer flags how many more are hidden behind the search.
|
||||
const MAX_VISIBLE = 50;
|
||||
const visible = filtered.slice(0, MAX_VISIBLE);
|
||||
const hiddenCount = filtered.length - visible.length;
|
||||
|
||||
const toggle = (v: string) =>
|
||||
onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. */}
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps({
|
||||
onMouseDown: (e) => {
|
||||
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the
|
||||
// list — the field's focus is the single source of "open", so a caret click never toggles
|
||||
// it shut.
|
||||
if (e.target !== inputRef.current) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpen(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-xs">
|
||||
<span className="max-w-36 truncate">{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={() => toggle(v)}
|
||||
aria-label={t("common.remove", { defaultValue: "Remove" })}
|
||||
className="hover:opacity-70"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Backspace on an empty field removes the last chip (react-select behaviour).
|
||||
if (e.key === "Backspace" && query === "" && selected.length > 0) {
|
||||
onChange(selected.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
selected.length === 0
|
||||
? (searchPlaceholder ?? t("common.search", { defaultValue: "Search" }))
|
||||
: ""
|
||||
}
|
||||
aria-label={label}
|
||||
className="min-w-16 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>
|
||||
|
||||
{open && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
// Keep the panel hidden until floating-ui has computed a real position — otherwise the first
|
||||
// frame paints it at the container's top-left before autoUpdate runs. (isPositioned guard,
|
||||
// same as PopoverSurface.)
|
||||
style={{ ...floatingStyles, visibility: isPositioned ? "visible" : "hidden" }}
|
||||
{...getFloatingProps()}
|
||||
aria-label={label}
|
||||
className="z-popover"
|
||||
>
|
||||
{/* The entrance animation + glass styling live ONE level in: popIn animates `transform`
|
||||
(scale/translateY), which on the positioned element itself would OVERRIDE floating-ui's
|
||||
transform-based position and flash the panel at the origin for the animation's duration.
|
||||
Keeping it on a child leaves the outer element's position transform untouched. */}
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
className="glass-menu max-h-64 w-full overflow-y-auto rounded-xl p-1.5 no-scrollbar shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{t("common.noResults", { defaultValue: "No matches" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{visible.map((o) => {
|
||||
const on = selected.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
// Keep the field focused (don't blur to the button) so typing continues.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
toggle(o.value);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition hover:bg-surface ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<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.value}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">
|
||||
{o.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Layers, ListMusic, Plus, Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import MultiSelectChips from "./MultiSelectChips";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
@@ -205,15 +203,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
{
|
||||
id: "collection",
|
||||
title: t("plex.filter.collection"),
|
||||
body: filters.collection ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
body: (
|
||||
<CollectionPicker
|
||||
value={filters.collection}
|
||||
title={filters.collectionTitle}
|
||||
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
|
||||
/>
|
||||
),
|
||||
@@ -248,7 +241,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<MultiSelectChips
|
||||
<Combobox
|
||||
label={t("plex.filter.genre")}
|
||||
options={facets.genres}
|
||||
selected={filters.genres}
|
||||
@@ -329,7 +322,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "contentrating",
|
||||
title: t("plex.filter.contentRating"),
|
||||
body: (
|
||||
<MultiSelectChips
|
||||
<Combobox
|
||||
label={t("plex.filter.contentRating")}
|
||||
options={facets.content_ratings}
|
||||
selected={filters.contentRatings}
|
||||
@@ -345,7 +338,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "studio",
|
||||
title: t("plex.filter.studio"),
|
||||
body: (
|
||||
<MultiSelectChips
|
||||
<Combobox
|
||||
label={t("plex.filter.studio")}
|
||||
options={facets.studios}
|
||||
selected={filters.studios}
|
||||
@@ -383,103 +376,55 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
);
|
||||
}
|
||||
|
||||
// The collection filter as a compact "+ Add" → searchable popover (E5 S7), matching the genre/studio
|
||||
// pickers instead of an always-expanded inline search + list. Single-select: picking a collection sets
|
||||
// the filter and closes; the selected one then shows as a removable chip (handled by the caller).
|
||||
function CollectionPicker({ onPick }: { onPick: (id: string, title: string) => void }) {
|
||||
// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the
|
||||
// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The
|
||||
// generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
|
||||
// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing
|
||||
// the chip drops it.
|
||||
function CollectionPicker({
|
||||
value,
|
||||
title,
|
||||
onPick,
|
||||
}: {
|
||||
value: string | null | undefined;
|
||||
title: string | null | undefined;
|
||||
onPick: (id: string | null, title: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchDeb = useDebounced(search.trim(), 300);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fade = useScrollFade();
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("union", searchDeb),
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
enabled: open, // only fetch while the picker is open
|
||||
});
|
||||
const collections = q.data?.collections ?? [];
|
||||
// Search narrows server-side; cap the client render so a large union (hundreds) stays light.
|
||||
const visibleCollections = collections.slice(0, 50);
|
||||
const hiddenCollections = collections.length - visibleCollections.length;
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
hostsInput: true,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
else setSearch("");
|
||||
}, [open]);
|
||||
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: c.title,
|
||||
count: c.child_count ?? undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("plex.filter.add")}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted transition hover:text-accent"
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("plex.filter.add")}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("plex.filter.collection")}
|
||||
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="relative mb-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") return; // let the popover dismiss handle it
|
||||
e.stopPropagation(); // keep text editing out of the surface's list-nav/typeahead
|
||||
}}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</div>
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
{collections.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">{t("plex.filter.collectionEmpty")}</p>
|
||||
) : (
|
||||
<>
|
||||
{visibleCollections.map((c) => (
|
||||
<PopoverItem
|
||||
key={c.id}
|
||||
role="option"
|
||||
onSelect={() => {
|
||||
onPick(c.id, c.title);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={c.title}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted outline-hidden transition"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{c.title}</span>
|
||||
{c.child_count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">
|
||||
{c.child_count}
|
||||
</span>
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
{hiddenCollections > 0 && (
|
||||
<p className="px-2 py-1.5 text-2xs text-muted">
|
||||
{t("plex.filter.moreRefine", { count: hiddenCollections })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
<Combobox
|
||||
label={t("plex.filter.collection")}
|
||||
multiple={false}
|
||||
clientFilter={false} // the server already narrows to `search`
|
||||
options={options}
|
||||
selected={value ? [value] : []}
|
||||
selectedLabels={value && title ? { [value]: title } : undefined}
|
||||
query={search}
|
||||
onQueryChange={setSearch}
|
||||
onOpenChange={setOpen}
|
||||
loading={q.isFetching}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
emptyText={t("plex.filter.collectionEmpty")}
|
||||
onChange={(next) => {
|
||||
const id = next[0] ?? null;
|
||||
const picked = options.find((o) => o.value === id);
|
||||
onPick(id, id ? (picked?.label ?? title ?? id) : null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user