E5 S7: Plex filter UI — listbox-select pickers + studio filter

Reworks the always-expanded filter chip walls into compact, Seerr-style
"+ Add" → searchable-popover pickers (the user's space-saving ask), and adds
the missing studio filter.

- New MultiSelectChips: selected values as removable chips + a "+ Add" trigger
  opening a searchable checklist (AnchoredPopover platform — menuitemcheckbox,
  arrow/Escape, anchor flip). Options carry facet counts; a large vocabulary
  renders a 50-row cap + "N more — refine with search" footer (studios run to
  ~1500). Replaces the flat Genre + Age-rating chip lists.
- New Studio filter via the same picker. Backend: /facets now aggregates the
  studio vocabulary (distinct studio + count, self-excluded like genres). The
  studio filter itself already existed (only addable from the detail page before).
- Collection converted to the same "+ Add" pattern (CollectionPicker): a
  single-select popover with server-side search + the 50-cap; the selected
  collection still shows as a removable chip. Retires the always-inline search+list.

New i18n plex.filter.studio/add/moreRefine + common.search/remove/noResults
(en+hu). Tests: MultiSelectChips.dom.test.tsx (5, incl. the cap) +
test_plex_facets studio-vocabulary. Browser-E2E'd: Genre→Horror(402),
Studio→A24(14, capped/searched), Collection→James Bond(26)/harry-search.
Gate: vitest 342, backend lane 403.
This commit is contained in:
2026-08-21 00:40:31 +02:00
parent 187b8007f8
commit d56a791c04
10 changed files with 470 additions and 97 deletions
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
//
// E5 S7: the compact listbox-select + chips multi-value filter. Covers the selected-chip removal, the
// popover open, the search filter, and toggling an option on. i18n is key-passthrough; the popover is
// floating-ui based (ActionMenu's test recipe: stub the two observers autoUpdate wires, wrap in a
// FloatingTree, and query the hidden surface with `{ hidden: true }` since jsdom never positions it).
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import MultiSelectChips from "./MultiSelectChips";
afterEach(cleanup);
const OPTIONS = [
{ value: "Drama", count: 120 },
{ value: "Action", count: 90 },
{ value: "Comedy", count: 60 },
];
function wrap(ui: React.ReactElement) {
return render(<FloatingTree>{ui}</FloatingTree>);
}
describe("MultiSelectChips", () => {
it("renders selected values as removable chips and removes on ✕", () => {
const onChange = vi.fn();
wrap(
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
);
expect(screen.getByText("Drama")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
});
it("keeps the option list unmounted until the trigger is clicked", () => {
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3);
});
it("filters the options by the search query", () => {
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
fireEvent.change(screen.getByPlaceholderText("common.search"), { target: { value: "com" } });
const items = screen.getAllByRole("menuitemcheckbox", { hidden: true });
expect(items.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy");
});
it("toggles an option on with its value added to the selection", () => {
const onChange = vi.fn();
wrap(
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
});
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50);
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
});
});
@@ -0,0 +1,169 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Plus, Search, X } from "lucide-react";
import { useScrollFade } from "../lib/useScrollFade";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A compact "listbox-select + removable chips" multi-value filter control (E5 S7), replacing a flat
// wall of toggle chips: the selected values show as removable chips, and a "+" trigger opens a
// searchable checklist popover (AnchoredPopover platform — menuitemcheckbox items, arrow/Escape, anchor
// flip). Options carry an optional count. Space-saving for a large vocabulary (genres / studios), which
// is exactly why it replaces the always-expanded chip lists in the Plex filter panel.
export interface MultiSelectOption {
value: string;
count?: number;
}
export default function MultiSelectChips({
label,
options,
selected,
onChange,
addLabel,
searchPlaceholder,
}: {
/** Accessible name for the popover + trigger (e.g. "Genre"). */
label: string;
options: readonly MultiSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
addLabel?: string;
searchPlaceholder?: string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade();
// A free-text search field lives in the surface — keep typed chars + pointer-hover out of the roving
// list so neither steals the field's caret (same reason ChannelTags' tag editor sets this).
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
hostsInput: true,
});
// Focus the search field when the menu opens; clear the query when it closes.
useEffect(() => {
if (open) inputRef.current?.focus();
else setQuery("");
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? options.filter((o) => o.value.toLowerCase().includes(q)) : options;
}, [options, query]);
// A large vocabulary (studios can run to hundreds) must not render every option as a DOM node — cap
// the rendered rows and let search narrow to the rest. The options arrive count-desc, so the default
// view is the most-common values; a footer flags how many more are hidden behind the search.
const MAX_VISIBLE = 50;
const visible = filtered.slice(0, MAX_VISIBLE);
const hiddenCount = filtered.length - visible.length;
const toggle = (v: string) =>
onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]);
return (
<div>
{selected.length > 0 && (
<div className="mb-1.5 flex flex-wrap gap-1.5">
{selected.map((v) => (
<span key={v} className="chip px-2 py-1 text-xs">
<span className="max-w-36 truncate">{v}</span>
<button
onClick={() => toggle(v)}
aria-label={t("common.remove", { defaultValue: "Remove" })}
className="hover:opacity-70"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<button
ref={pop.refs.setReference}
type="button"
aria-label={addLabel ?? t("plex.filter.add")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted transition hover:text-accent"
{...pop.getReferenceProps()}
>
<Plus className="h-3.5 w-3.5" />
{addLabel ?? t("plex.filter.add")}
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="relative mb-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss (close + refocus trigger); every other key —
// typed chars, arrows, Home/End — is text editing here, kept out of the surface's
// list-navigation/typeahead handlers so the caret behaves.
if (e.key === "Escape") return;
e.stopPropagation();
}}
placeholder={searchPlaceholder ?? t("common.search", { defaultValue: "Search" })}
className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
<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)}
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>
)}
</>
)}
</div>
</PopoverSurface>
</div>
);
}
+145 -92
View File
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { Layers, ListMusic, Plus, Search, SlidersHorizontal, X } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource";
@@ -12,7 +13,8 @@ import { useScrollFade } from "../lib/useScrollFade";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterChipGroup from "./FilterChipGroup";
import MultiSelectChips from "./MultiSelectChips";
import { usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
@@ -68,7 +70,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex();
const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
@@ -93,15 +94,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
});
const facets = facetsQ.data;
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
queryKey: qk.plexCollections("union", collSearchDeb),
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection,
});
const collections = collectionsQ.data?.collections ?? [];
const fCount = plexFilterCount(filters);
// Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only
// the filters that still live in this panel (expanded filters + watch-state).
@@ -109,15 +101,14 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
const anyActive = activeCount > 0;
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
const toggleIn = (arr: string[], v: string) =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
const clearAll = usePlexClearFilters();
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
const peopleActive =
filters.directors.length + filters.actors.length + filters.studios.length > 0;
// Directors + actors have no picker (they're added from the detail/cast surfaces) — this section
// just lists them as removable chips. Studios now have their own picker section (below).
const peopleActive = filters.directors.length + filters.actors.length > 0;
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
@@ -206,13 +197,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
/>
))}
{filters.studios.map((s) => (
<RemovableChip
key={`s:${s}`}
label={s}
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
/>
))}
</div>
),
},
@@ -229,36 +213,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
/>
</div>
) : (
<div>
<input
value={collSearch}
onChange={(e) => setCollSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-surface px-2 py-1 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<div
ref={collFade.ref}
style={collFade.style}
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto no-scrollbar"
>
{collections.map((c) => (
<button
key={c.id}
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
>
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{c.title}</span>
<span className="text-xs text-muted">{c.child_count}</span>
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">
{t("plex.filter.collectionEmpty")}
</span>
)}
</div>
</div>
<CollectionPicker
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
/>
),
},
{
@@ -284,27 +241,19 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
body: (
<>
{filters.genres.length > 1 && (
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<div className="mb-1.5">
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
</div>
)}
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("plex.filter.genre")}
>
{facets.genres.map((g) => (
<button
key={g.value}
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
aria-pressed={filters.genres.includes(g.value)}
className={filterChipClass}
>
{g.value}
</button>
))}
</div>
<MultiSelectChips
label={t("plex.filter.genre")}
options={facets.genres}
selected={filters.genres}
onChange={(next) => patch({ genres: next })}
/>
</>
),
},
@@ -380,24 +329,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("plex.filter.contentRating")}
>
{facets.content_ratings.map((c) => (
<button
key={c.value}
onClick={() =>
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
}
aria-pressed={filters.contentRatings.includes(c.value)}
className={filterChipClass}
>
{c.value}
</button>
))}
</div>
<MultiSelectChips
label={t("plex.filter.contentRating")}
options={facets.content_ratings}
selected={filters.contentRatings}
onChange={(next) => patch({ contentRatings: next })}
/>
),
},
]
: []),
...(facets && facets.studios.length > 0
? [
{
id: "studio",
title: t("plex.filter.studio"),
body: (
<MultiSelectChips
label={t("plex.filter.studio")}
options={facets.studios}
selected={filters.studios}
onChange={(next) => patch({ studios: next })}
/>
),
},
]
@@ -430,6 +383,106 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
);
}
// The collection filter as a compact "+ Add" → searchable popover (E5 S7), matching the genre/studio
// pickers instead of an always-expanded inline search + list. Single-select: picking a collection sets
// the filter and closes; the selected one then shows as a removable chip (handled by the caller).
function CollectionPicker({ onPick }: { onPick: (id: string, title: string) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const searchDeb = useDebounced(search.trim(), 300);
const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade();
const q = useQuery({
queryKey: qk.plexCollections("union", searchDeb),
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
enabled: open, // only fetch while the picker is open
});
const collections = q.data?.collections ?? [];
// Search narrows server-side; cap the client render so a large union (hundreds) stays light.
const visibleCollections = collections.slice(0, 50);
const hiddenCollections = collections.length - visibleCollections.length;
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
hostsInput: true,
});
useEffect(() => {
if (open) inputRef.current?.focus();
else setSearch("");
}, [open]);
return (
<>
<button
ref={pop.refs.setReference}
type="button"
aria-label={t("plex.filter.add")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted transition hover:text-accent"
{...pop.getReferenceProps()}
>
<Plus className="h-3.5 w-3.5" />
{t("plex.filter.add")}
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("plex.filter.collection")}
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="relative mb-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") return; // let the popover dismiss handle it
e.stopPropagation(); // keep text editing out of the surface's list-nav/typeahead
}}
placeholder={t("plex.filter.collectionSearch")}
className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{collections.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted">{t("plex.filter.collectionEmpty")}</p>
) : (
<>
{visibleCollections.map((c) => (
<PopoverItem
key={c.id}
role="option"
onSelect={() => {
onPick(c.id, c.title);
setOpen(false);
}}
label={c.title}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted outline-hidden transition"
>
<Layers className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{c.title}</span>
{c.child_count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">
{c.child_count}
</span>
)}
</PopoverItem>
))}
{hiddenCollections > 0 && (
<p className="px-2 py-1.5 text-2xs text-muted">
{t("plex.filter.moreRefine", { count: hiddenCollections })}
</p>
)}
</>
)}
</div>
</PopoverSurface>
</>
);
}
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="chip px-2 py-1 text-xs">