fix(e5): rebuild the filter combi-input on floating-ui primitives (focus, dismiss, z-order)

The AnchoredPopover-based version had three UAT-caught bugs: the field
showed an ugly "Add" placeholder; the first click couldn't type (the
platform's FloatingFocusManager + focusItemOnOpen pulled focus onto the
list, blurring the field until a second click); and an outside click didn't
close the list. A visual pass also caught the dropdown rendering BEHIND the
sidebar panel (z-menu 20 < z-panel 35).

Rebuilt on floating-ui primitives directly — no focus manager, so focus
stays in the field; open is driven by field focus; useDismiss closes on
outside-press/Escape; the list is a z-popover (60) FloatingPortal sized to
the field width. Placeholder is now a plain "Search" (empty once chips
exist). Verified in the browser: type-on-first-click, filter, chip add,
outside-close, and the list painting above the sidebar.
This commit is contained in:
2026-08-21 03:37:14 +02:00
parent b83248c510
commit 29f2c9059e
2 changed files with 131 additions and 87 deletions
@@ -54,9 +54,9 @@ describe("MultiSelectChips", () => {
it("keeps the option list unmounted until the field is focused", () => {
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull();
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
fireEvent.focus(screen.getByRole("textbox"));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3);
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3);
});
it("filters the options by the typed query", () => {
@@ -64,7 +64,7 @@ describe("MultiSelectChips", () => {
const field = screen.getByRole("textbox");
fireEvent.focus(field);
fireEvent.change(field, { target: { value: "com" } });
const items = screen.getAllByRole("menuitemcheckbox", { hidden: true });
const items = screen.getAllByRole("option", { hidden: true });
expect(items.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy");
});
@@ -98,7 +98,7 @@ describe("MultiSelectChips", () => {
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("menuitemcheckbox", { hidden: true }).length).toBe(50);
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
});
});
+127 -83
View File
@@ -1,16 +1,29 @@
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";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A "combi input" multi-value filter control (E5 S7, reshaped): the selected values are removable
// chips that live INSIDE a text field you can type into (the text filters the vocabulary), and the
// searchable checklist opens below on focus — a react-select / Overseerr-style combobox. It replaces
// the earlier "+Add button opening a popover" so a large vocabulary (genres / studios) reads as one
// familiar input rather than a wall of toggle chips. Positioning + outside-dismiss come from the
// AnchoredPopover platform (refs only — open is driven by the field's focus so a click to place the
// caret never toggles the list shut); the field's own keydown owns Escape + backspace-to-remove.
// 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;
@@ -22,15 +35,14 @@ export default function MultiSelectChips({
options,
selected,
onChange,
addLabel,
searchPlaceholder,
}: {
/** Accessible name for the field + listbox (e.g. "Genre"). */
/** 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;
addLabel?: string;
/** Optional placeholder for the empty field; defaults to a plain "Search". */
searchPlaceholder?: string;
}) {
const { t } = useTranslation();
@@ -38,15 +50,32 @@ export default function MultiSelectChips({
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade();
// hostsInput keeps the platform's list-navigation/typeahead from stealing the field's caret; open is
// controlled here so the field's focus (not a click-to-toggle) drives it.
const pop = useAnchoredPopover({
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
hostsInput: true,
whileElementsMounted: autoUpdate,
middleware: [
offset(4),
flip({ padding: 8 }),
shift({ padding: 8 }),
size({
padding: 8,
apply({ rects, elements, availableHeight }) {
// Match the field width and cap to the space below (or 320px, whichever is smaller).
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
maxHeight: `${Math.min(availableHeight, 320)}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("");
@@ -71,16 +100,19 @@ export default function MultiSelectChips({
<div>
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. */}
<div
ref={pop.refs.setReference}
onMouseDown={(e) => {
// A click anywhere in the box (padding, a chip gap) focuses the field and opens the list —
// never toggles it shut; the field's onFocus is the single source of "open".
if (e.target !== inputRef.current) {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
}}
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) => (
@@ -117,75 +149,87 @@ export default function MultiSelectChips({
// 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));
return;
}
e.stopPropagation(); // typed chars / arrows / Home / End are text editing, not list nav
}}
placeholder={
selected.length === 0
? (addLabel ?? t("plex.filter.add"))
: (searchPlaceholder ?? t("common.search", { defaultValue: "Search" }))
? (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"
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-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{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 (
<PopoverItem
key={o.value}
role="menuitemcheckbox"
checked={on}
onSelect={() => {
toggle(o.value);
inputRef.current?.focus(); // keep the field hot for the next pick / more typing
}}
label={o.value}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-hidden transition ${
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>
)}
</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",
})}
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
aria-label={label}
className="glass-menu z-popover min-w-48 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div
ref={fade.ref}
style={fade.style}
className="max-h-64 overflow-y-auto no-scrollbar"
>
{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>
</PopoverSurface>
</div>
</div>
</FloatingPortal>
)}
</div>
);
}