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:
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user