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:
2026-08-21 05:43:36 +02:00
parent 0c746f5bcd
commit 5316f48af2
6 changed files with 571 additions and 476 deletions
+111 -30
View File
@@ -2,6 +2,7 @@ import {
createContext, createContext,
useContext, useContext,
useEffect, useEffect,
useId,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, 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 // 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 // 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. // 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. // 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 { export interface AnchoredPopoverOptions {
open: boolean; open: boolean;
onOpenChange: (open: boolean, event?: Event) => void; 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; mode?: PopoverMode;
placement?: Placement; placement?: Placement;
/** Gap between trigger and panel, px. Default 4. */ /** Gap between trigger and panel, px. Default 4. */
@@ -99,9 +115,14 @@ type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
export function useAnchoredPopover(opts: AnchoredPopoverOptions) { export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const mode = opts.mode ?? "menu"; const mode = opts.mode ?? "menu";
const isHover = mode === "hover"; 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 // 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. // arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
const isNav = mode === "menu" || mode === "listbox"; 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. // null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
const nodeId = useFloatingNodeId(); 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 }), hide({ padding: 8 }),
], ],
}); });
@@ -156,7 +190,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
} }
}, [opts.open]); }, [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, { const hover = useHover(context, {
enabled: isHover, enabled: isHover,
move: false, 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- // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
// press stays with floating-ui (tree-aware). // press stays with floating-ui (tree-aware).
const dismiss = useDismiss(context, { escapeKey: false }); 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, { 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, listRef: elementsRef,
activeIndex, activeIndex,
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from // 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. // `isListIndexDisabled`.) No explicit index list needed.
loop: true, loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, // 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). // which strands focus on the menu container — arrows then can't enter the roving list at all). A
focusItemOnOpen: true, // combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely // option; auto-highlighting item 0 on open would fight the typed query.
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next focusItemOnOpen: isCombobox ? false : true,
// Enter to the hovered item. Turned off together with typeahead for such menus. // A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when
focusItemOnHover: !opts.hostsInput, // 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, { const typeahead = useTypeahead(context, {
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput, enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
@@ -222,6 +271,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
nodeId, nodeId,
mode, mode,
isNav, isNav,
isCombobox,
roves,
open: opts.open, open: opts.open,
selectedIndex: opts.selectedIndex ?? null, selectedIndex: opts.selectedIndex ?? null,
refs, refs,
@@ -245,6 +296,9 @@ interface PopoverListContextValue {
highlightIndex: number | null; highlightIndex: number | null;
setHoverIndex: (index: number | null) => void; setHoverIndex: (index: number | null) => void;
getItemProps: AnchoredPopover["getItemProps"]; 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); const PopoverListContext = createContext<PopoverListContextValue | null>(null);
@@ -302,6 +356,12 @@ export function PopoverItem({
label?: string; label?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) { } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
const ctx = useContext(PopoverListContext); 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 textLabel = label ?? (typeof children === "string" ? children : undefined);
const { ref, index } = useListItem({ label: textLabel }); const { ref, index } = useListItem({ label: textLabel });
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight // 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 focusActive = ctx?.activeIndex === index;
const visualActive = ctx?.highlightIndex === index; const visualActive = ctx?.highlightIndex === index;
const ariaProps = // The checked/selected state is rendered as an EXPLICIT attribute after {...handlers} below (so it
role === "menuitemradio" || role === "menuitemcheckbox" // wins): for a combobox `role:"combobox"` makes getItemProps emit its own `aria-selected` off the
? { role, "aria-checked": !!checked } // single selectedIndex, which is always false for a MULTI-select — our per-value truth must override.
: role === "option" const ariaChecked =
? { role, "aria-selected": !!selected } role === "menuitemradio" || role === "menuitemcheckbox" ? !!checked : undefined;
: { role }; const ariaSelected = role === "option" ? !!selected : undefined;
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which // 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 // composes handlers rather than dropping them — spreading `{...rest}` separately would let the
@@ -332,19 +392,37 @@ export function PopoverItem({
rest.onMouseMove?.(e); rest.onMouseMove?.(e);
if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index); 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 const handlers = ctx
? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove }) ? ctx.getItemProps({
: { ...rest, onClick: mergedClick }; ...rest,
onClick: mergedClick,
onMouseMove: mergedMouseMove,
onMouseDown: mergedMouseDown,
})
: { ...rest, onClick: mergedClick, onMouseDown: mergedMouseDown };
return ( return (
<button <button
ref={ref} ref={ref}
type="button" type="button"
{...ariaProps} role={role}
{...handlers} {...handlers}
// These win over anything in `handlers` — the roving tabindex must not be a caller override, // A virtual option carries its own stable id (the aria-activedescendant target); it wins over any
// and disabled/className/data-active are the platform's to set. // id Floating UI's getItemProps would inject on activation.
tabIndex={focusActive ? 0 : -1} 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} data-active={visualActive ? "true" : undefined}
disabled={disabled} disabled={disabled}
className={`outline-hidden ${ className={`outline-hidden ${
@@ -409,6 +487,8 @@ function PopoverSurfaceInner({
isPositioned, isPositioned,
mode, mode,
isNav, isNav,
isCombobox,
roves,
selectedIndex, selectedIndex,
activeIndex, activeIndex,
highlightIndex, highlightIndex,
@@ -441,9 +521,10 @@ function PopoverSurfaceInner({
if (ariaLabel) delete floatingProps["aria-labelledby"]; if (ariaLabel) delete floatingProps["aria-labelledby"];
const zClass = mode === "hover" ? "z-tooltip" : "z-popover"; const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// Everything but a passive hover card manages focus by default (trap into the surface, return to // A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
// the trigger on close). // passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
const managed = focusManaged ?? mode !== "hover"; // is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
const surface = ( const surface = (
<div <div
@@ -477,11 +558,11 @@ function PopoverSurfaceInner({
</div> </div>
); );
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested // Menu/listbox AND combobox surfaces host the item registry (FloatingList) + active-state context so
// <PopoverItem>s can self-register and rove. // nested <PopoverItem>s can self-register and rove/aria-activedescendant.
const positioned = isNav ? ( const positioned = roves ? (
<PopoverListContext.Provider <PopoverListContext.Provider
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps }} value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps, virtual: isCombobox }}
> >
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}> <FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface} {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
});
});
+267
View File
@@ -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>
);
}
+47 -102
View File
@@ -1,20 +1,18 @@
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n"; import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, Search, SlidersHorizontal, X } from "lucide-react"; import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { api, plexFilterCount, type PlexFilters } from "../lib/api"; import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { type PanelLayout } from "../lib/panelLayout"; import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource"; import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
import FilterRail from "./FilterRail"; import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups"; import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle"; import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup"; import FilterChipGroup from "./FilterChipGroup";
import MultiSelectChips from "./MultiSelectChips"; import Combobox, { type ComboboxOption } from "./Combobox";
import { usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider"; import { usePlex, usePlexActions } from "./PlexProvider";
@@ -205,15 +203,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
{ {
id: "collection", id: "collection",
title: t("plex.filter.collection"), title: t("plex.filter.collection"),
body: filters.collection ? ( body: (
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
) : (
<CollectionPicker <CollectionPicker
value={filters.collection}
title={filters.collectionTitle}
onPick={(id, title) => patch({ collection: id, collectionTitle: title })} onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
/> />
), ),
@@ -248,7 +241,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
/> />
</div> </div>
)} )}
<MultiSelectChips <Combobox
label={t("plex.filter.genre")} label={t("plex.filter.genre")}
options={facets.genres} options={facets.genres}
selected={filters.genres} selected={filters.genres}
@@ -329,7 +322,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "contentrating", id: "contentrating",
title: t("plex.filter.contentRating"), title: t("plex.filter.contentRating"),
body: ( body: (
<MultiSelectChips <Combobox
label={t("plex.filter.contentRating")} label={t("plex.filter.contentRating")}
options={facets.content_ratings} options={facets.content_ratings}
selected={filters.contentRatings} selected={filters.contentRatings}
@@ -345,7 +338,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "studio", id: "studio",
title: t("plex.filter.studio"), title: t("plex.filter.studio"),
body: ( body: (
<MultiSelectChips <Combobox
label={t("plex.filter.studio")} label={t("plex.filter.studio")}
options={facets.studios} options={facets.studios}
selected={filters.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 // The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the
// pickers instead of an always-expanded inline search + list. Single-select: picking a collection sets // genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The
// the filter and closes; the selected one then shows as a removable chip (handled by the caller). // generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
function CollectionPicker({ onPick }: { onPick: (id: string, title: string) => void }) { // `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 { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const searchDeb = useDebounced(search.trim(), 300); const searchDeb = useDebounced(search.trim(), 300);
const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade();
const q = useQuery({ const q = useQuery({
queryKey: qk.plexCollections("union", searchDeb), queryKey: qk.plexCollections("union", searchDeb),
queryFn: () => api.plexCollections(undefined, searchDeb || undefined), queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
enabled: open, // only fetch while the picker is open enabled: open, // only fetch while the picker is open
}); });
const collections = q.data?.collections ?? []; const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
// Search narrows server-side; cap the client render so a large union (hundreds) stays light. value: c.id,
const visibleCollections = collections.slice(0, 50); label: c.title,
const hiddenCollections = collections.length - visibleCollections.length; count: c.child_count ?? undefined,
const pop = useAnchoredPopover({ }));
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
hostsInput: true,
});
useEffect(() => {
if (open) inputRef.current?.focus();
else setSearch("");
}, [open]);
return ( return (
<> <Combobox
<button label={t("plex.filter.collection")}
ref={pop.refs.setReference} multiple={false}
type="button" clientFilter={false} // the server already narrows to `search`
aria-label={t("plex.filter.add")} options={options}
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" selected={value ? [value] : []}
{...pop.getReferenceProps()} selectedLabels={value && title ? { [value]: title } : undefined}
> query={search}
<Plus className="h-3.5 w-3.5" /> onQueryChange={setSearch}
{t("plex.filter.add")} onOpenChange={setOpen}
</button> loading={q.isFetching}
<PopoverSurface placeholder={t("plex.filter.collectionSearch")}
popover={pop} emptyText={t("plex.filter.collectionEmpty")}
ariaLabel={t("plex.filter.collection")} onChange={(next) => {
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]" const id = next[0] ?? null;
> const picked = options.find((o) => o.value === id);
<div className="relative mb-1"> onPick(id, id ? (picked?.label ?? title ?? id) : null);
<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>
</>
); );
} }