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" })}
-