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", () => {
|
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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user