feat(filters): the any/all switch sits on the panel above its picker

ui/MatchToggle replaces the Combobox popover footer (Feed tags, topics,
languages; Plex collections) and PlexSidebar's own toggle (genres,
actors): shown from two picks, one wording. A closed picker had hidden
that "all" was on.
This commit is contained in:
2026-09-27 07:12:51 +02:00
parent 955c23d321
commit f48f16830e
12 changed files with 159 additions and 168 deletions
+36 -65
View File
@@ -12,7 +12,7 @@ import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./ui/PanelGroups";
import SegmentedToggle from "./ui/SegmentedToggle";
import MatchToggle from "./ui/MatchToggle";
import FilterChipGroup from "./ui/FilterChipGroup";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import SavedViewsPopover from "./SavedViewsPopover";
@@ -43,31 +43,6 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
{ key: "long", min: 120 * 60, max: null },
];
// The any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared
// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle).
function MatchToggle({
mode,
onChange,
}: {
mode: "any" | "all";
onChange: (m: "any" | "all") => void;
}) {
const { t } = useTranslation();
return (
<SegmentedToggle
size="sm"
label={t("plex.filter.matchLabel")}
ariaLabel={t("plex.filter.matchLabel")}
options={[
{ value: "any", label: t("plex.filter.match.any") },
{ value: "all", label: t("plex.filter.match.all") },
]}
value={mode}
onChange={onChange}
/>
);
}
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
const { t } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
@@ -131,14 +106,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
/>
))}
{filters.actors.length > 1 && (
<div className="w-full">
<MatchToggle
mode={filters.actorMode ?? "any"}
onChange={(m) => patch({ actorMode: m })}
/>
</div>
)}
<MatchToggle
picked={filters.actors.length}
className="w-full"
value={filters.actorMode ?? "any"}
onChange={(m) => patch({ actorMode: m })}
/>
{filters.actors.map((a) => (
<RemovableChip
key={`a:${a}`}
@@ -186,14 +159,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
title: t("plex.filter.genre"),
body: (
<>
{filters.genres.length > 1 && (
<div className="mb-1.5">
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
</div>
)}
<MatchToggle
picked={filters.genres.length}
value={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<Combobox
label={t("plex.filter.genre")}
options={facets.genres}
@@ -472,29 +442,30 @@ function CollectionPicker({
for (const o of options) if (o.label) labelMap[o.value] = o.label;
return (
<Combobox
label={t("plex.filter.collection")}
clientFilter={false} // the server already narrows to `search`
options={options}
selected={values}
selectedLabels={resolved}
query={search}
onQueryChange={setSearch}
onOpenChange={setOpen}
loading={q.isFetching}
placeholder={t("plex.filter.collectionSearch")}
emptyText={t("plex.filter.collectionEmpty")}
matchMode={mode === "all" ? "and" : "or"}
onMatchModeChange={(m) => onModeChange(m === "and" ? "all" : "any")}
onChange={(next) => {
// Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an
// empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes
// differently from an absent key), mirroring the chip-row remove handler.
const nextTitles: Record<string, string> = {};
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
onChange(next, next.length ? nextTitles : undefined);
}}
/>
<>
<MatchToggle picked={values.length} value={mode} onChange={onModeChange} />
<Combobox
label={t("plex.filter.collection")}
clientFilter={false} // the server already narrows to `search`
options={options}
selected={values}
selectedLabels={resolved}
query={search}
onQueryChange={setSearch}
onOpenChange={setOpen}
loading={q.isFetching}
placeholder={t("plex.filter.collectionSearch")}
emptyText={t("plex.filter.collectionEmpty")}
onChange={(next) => {
// Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an
// empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes
// differently from an absent key), mirroring the chip-row remove handler.
const nextTitles: Record<string, string> = {};
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
onChange(next, next.length ? nextTitles : undefined);
}}
/>
</>
);
}