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", () => { it("keeps the option list unmounted until the field is focused", () => {
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />); 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")); 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", () => { it("filters the options by the typed query", () => {
@@ -64,7 +64,7 @@ describe("MultiSelectChips", () => {
const field = screen.getByRole("textbox"); const field = screen.getByRole("textbox");
fireEvent.focus(field); fireEvent.focus(field);
fireEvent.change(field, { target: { value: "com" } }); 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.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy"); 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 })); const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />); wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
fireEvent.focus(screen.getByRole("textbox")); 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 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 { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react"; 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 { 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 // A "combi input" multi-value filter control (E5 S7): the selected values are removable chips that
// chips that live INSIDE a text field you can type into (the text filters the vocabulary), and the // live INSIDE a text field you can type into (the text filters the vocabulary), and a checklist of
// searchable checklist opens below on focus — a react-select / Overseerr-style combobox. It replaces // options opens below on focus — a react-select / Overseerr-style combobox.
// 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 // Built on floating-ui PRIMITIVES directly, NOT the AnchoredPopover platform: that platform is menu-
// AnchoredPopover platform (refs only — open is driven by the field's focus so a click to place the // shaped (useClick to toggle + a FloatingFocusManager + focusItemOnOpen that pull focus onto the list
// caret never toggles the list shut); the field's own keydown owns Escape + backspace-to-remove. // 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 { export interface MultiSelectOption {
value: string; value: string;
@@ -22,15 +35,14 @@ export default function MultiSelectChips({
options, options,
selected, selected,
onChange, onChange,
addLabel,
searchPlaceholder, 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; label: string;
options: readonly MultiSelectOption[]; options: readonly MultiSelectOption[];
selected: string[]; selected: string[];
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
addLabel?: string; /** Optional placeholder for the empty field; defaults to a plain "Search". */
searchPlaceholder?: string; searchPlaceholder?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -38,15 +50,32 @@ export default function MultiSelectChips({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade(); 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 { refs, floatingStyles, context } = useFloating({
const pop = useAnchoredPopover({
open, open,
onOpenChange: setOpen, onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start", 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. // Clear the typed query when the list closes.
useEffect(() => { useEffect(() => {
if (!open) setQuery(""); if (!open) setQuery("");
@@ -71,16 +100,19 @@ export default function MultiSelectChips({
<div> <div>
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. */} {/* The field: removable chips + a free-text input + a chevron, styled as one input box. */}
<div <div
ref={pop.refs.setReference} ref={refs.setReference}
onMouseDown={(e) => { {...getReferenceProps({
// A click anywhere in the box (padding, a chip gap) focuses the field and opens the list — onMouseDown: (e) => {
// never toggles it shut; the field's onFocus is the single source of "open". // A press anywhere but the input (padding / a chip gap) focuses the field and opens the
if (e.target !== inputRef.current) { // list — the field's focus is the single source of "open", so a caret click never toggles
e.preventDefault(); // it shut.
inputRef.current?.focus(); if (e.target !== inputRef.current) {
setOpen(true); 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" 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) => ( {selected.map((v) => (
@@ -117,75 +149,87 @@ export default function MultiSelectChips({
// Backspace on an empty field removes the last chip (react-select behaviour). // Backspace on an empty field removes the last chip (react-select behaviour).
if (e.key === "Backspace" && query === "" && selected.length > 0) { if (e.key === "Backspace" && query === "" && selected.length > 0) {
onChange(selected.slice(0, -1)); onChange(selected.slice(0, -1));
return;
} }
e.stopPropagation(); // typed chars / arrows / Home / End are text editing, not list nav
}} }}
placeholder={ placeholder={
selected.length === 0 selected.length === 0
? (addLabel ?? t("plex.filter.add")) ? (searchPlaceholder ?? t("common.search", { defaultValue: "Search" }))
: (searchPlaceholder ?? t("common.search", { defaultValue: "Search" })) : ""
} }
aria-label={label} 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" /> <ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div> </div>
<PopoverSurface {open && (
popover={pop} <FloatingPortal>
ariaLabel={label} <div
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]" ref={refs.setFloating}
> style={floatingStyles}
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar"> {...getFloatingProps()}
{filtered.length === 0 ? ( aria-label={label}
<p className="px-2 py-1.5 text-xs text-muted"> className="glass-menu z-popover min-w-48 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
{t("common.noResults", { defaultValue: "No matches" })} >
</p> <div
) : ( ref={fade.ref}
<> style={fade.style}
{visible.map((o) => { className="max-h-64 overflow-y-auto no-scrollbar"
const on = selected.includes(o.value); >
return ( {filtered.length === 0 ? (
<PopoverItem <p className="px-2 py-1.5 text-xs text-muted">
key={o.value} {t("common.noResults", { defaultValue: "No matches" })}
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",
})}
</p> </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>
</div> </FloatingPortal>
</PopoverSurface> )}
</div> </div>
); );
} }