From 29f2c9059e32415089420526a508cce777cba73f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 21 Aug 2026 03:37:14 +0200 Subject: [PATCH] fix(e5): rebuild the filter combi-input on floating-ui primitives (focus, dismiss, z-order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/MultiSelectChips.dom.test.tsx | 8 +- frontend/src/components/MultiSelectChips.tsx | 210 +++++++++++------- 2 files changed, 131 insertions(+), 87 deletions(-) diff --git a/frontend/src/components/MultiSelectChips.dom.test.tsx b/frontend/src/components/MultiSelectChips.dom.test.tsx index 44b9bd59..5f5e1890 100644 --- a/frontend/src/components/MultiSelectChips.dom.test.tsx +++ b/frontend/src/components/MultiSelectChips.dom.test.tsx @@ -54,9 +54,9 @@ describe("MultiSelectChips", () => { it("keeps the option list unmounted until the field is focused", () => { wrap(); - 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(); 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 }); }); diff --git a/frontend/src/components/MultiSelectChips.tsx b/frontend/src/components/MultiSelectChips.tsx index 1e17e36d..0796d816 100644 --- a/frontend/src/components/MultiSelectChips.tsx +++ b/frontend/src/components/MultiSelectChips.tsx @@ -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(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({
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. */}
{ - // 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" />
- -
- {filtered.length === 0 ? ( -

- {t("common.noResults", { defaultValue: "No matches" })} -

- ) : ( - <> - {visible.map((o) => { - const on = selected.includes(o.value); - return ( - { - 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" - }`} - > - - {on && } - - {o.value} - {o.count != null && ( - {o.count} - )} - - ); - })} - {hiddenCount > 0 && ( -

- {t("plex.filter.moreRefine", { - count: hiddenCount, - defaultValue: "{{count}} more — refine with search", - })} + {open && ( + +

+
+ {filtered.length === 0 ? ( +

+ {t("common.noResults", { defaultValue: "No matches" })}

+ ) : ( + <> + {visible.map((o) => { + const on = selected.includes(o.value); + return ( + + ); + })} + {hiddenCount > 0 && ( +

+ {t("plex.filter.moreRefine", { + count: hiddenCount, + defaultValue: "{{count}} more — refine with search", + })} +

+ )} + )} - - )} -
- +
+
+ + )}
); }