diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index 1b90444f..2000f08b 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -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 (
-
- );
-}
-
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 && (
-
- patch({ actorMode: m })}
- />
-
- )}
+ patch({ actorMode: m })}
+ />
{filters.actors.map((a) => (
- {filters.genres.length > 1 && (
-
- patch({ genreMode: m })}
- />
-
- )}
+ patch({ genreMode: 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 = {};
- for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
- onChange(next, next.length ? nextTitles : undefined);
- }}
- />
+ <>
+
+ {
+ // 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 = {};
+ for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
+ onChange(next, next.length ? nextTitles : undefined);
+ }}
+ />
+ >
);
}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index abdbc506..cdfe128b 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -17,6 +17,7 @@ import ChannelPicker from "./ChannelPicker";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
+import MatchToggle from "./ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "./channelsContext";
import { useMe } from "../lib/useMe";
@@ -101,7 +102,8 @@ export default function Sidebar({
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
- // Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer.
+ // Each tag category (language/topic/other) has its OWN any/all match, set from the switch above its
+ // picker (ui/MatchToggle); the store keeps the older or/and words.
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
@@ -125,18 +127,23 @@ export default function Sidebar({
}
};
return (
- setTagMode("other", m)}
- />
+ <>
+ setTagMode("other", m === "all" ? "and" : "or")}
+ />
+
+ >
);
}
@@ -165,27 +172,37 @@ export default function Sidebar({
return ;
case "language":
return (
- setTagSelection(languages, next)}
- emptyText={t("sidebar.noMatchingTags")}
- matchMode={filters.tagModes.language}
- onMatchModeChange={(m) => setTagMode("language", m)}
- />
+ <>
+ setTagMode("language", m === "all" ? "and" : "or")}
+ />
+ setTagSelection(languages, next)}
+ emptyText={t("sidebar.noMatchingTags")}
+ />
+ >
);
case "topic":
return (
- setTagSelection(topics, next)}
- emptyText={t("sidebar.noMatchingTags")}
- matchMode={filters.tagModes.topic}
- onMatchModeChange={(m) => setTagMode("topic", m)}
- />
+ <>
+ setTagMode("topic", m === "all" ? "and" : "or")}
+ />
+ setTagSelection(topics, next)}
+ emptyText={t("sidebar.noMatchingTags")}
+ />
+ >
);
case "tags":
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
diff --git a/frontend/src/components/ui/Combobox.dom.test.tsx b/frontend/src/components/ui/Combobox.dom.test.tsx
index b62a5c3d..9daac7fd 100644
--- a/frontend/src/components/ui/Combobox.dom.test.tsx
+++ b/frontend/src/components/ui/Combobox.dom.test.tsx
@@ -205,31 +205,6 @@ describe("Combobox", () => {
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
- it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
- // The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
- // only at >= 2, and clicking 'all' reports the mode change out.
- const onMatchModeChange = vi.fn();
- const props = {
- label: "Topic",
- options: OPTIONS,
- onChange: vi.fn(),
- matchMode: "or" as const,
- onMatchModeChange,
- };
- // One selection: no footer.
- const { rerender } = wrap( );
- fireEvent.focus(screen.getByRole("combobox"));
- expect(screen.queryByText("sidebar.all")).toBeNull();
-
- // Two selections: footer appears with an any/all segment.
- rerender({ } );
- fireEvent.focus(screen.getByRole("combobox"));
- const all = screen.getByText("sidebar.all");
- expect(all).toBeTruthy();
- fireEvent.click(all);
- expect(onMatchModeChange).toHaveBeenCalledWith("and");
- });
-
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx
index de80f27e..812c702f 100644
--- a/frontend/src/components/ui/Combobox.tsx
+++ b/frontend/src/components/ui/Combobox.tsx
@@ -61,11 +61,6 @@ export interface ComboboxProps {
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
selectedLabels?: Record;
- /** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
- * "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
- * only once 2+ values are selected (below that, any and all are the same). */
- matchMode?: "or" | "and";
- onMatchModeChange?: (mode: "or" | "and") => void;
}
export default function Combobox({
@@ -84,8 +79,6 @@ export default function Combobox({
maxVisible = 50,
renderOption,
selectedLabels,
- matchMode,
- onMatchModeChange,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -299,32 +292,6 @@ export default function Combobox({
>
)}
- {/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
- once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
- open in the combobox's focus model. */}
- {matchMode != null && onMatchModeChange && selected.length >= 2 && (
-
-
{t("common.matchMode")}
-
- {(["or", "and"] as const).map((m) => (
- e.preventDefault()}
- onClick={() => onMatchModeChange(m)}
- className={`rounded-md border px-2 py-0.5 text-2xs transition ${
- matchMode === m
- ? "border-accent bg-accent/15 text-accent"
- : "border-border text-muted hover:border-accent/50 hover:text-fg"
- }`}
- >
- {t(m === "or" ? "sidebar.any" : "sidebar.all")}
-
- ))}
-
-
- )}
);
diff --git a/frontend/src/components/ui/MatchToggle.dom.test.tsx b/frontend/src/components/ui/MatchToggle.dom.test.tsx
new file mode 100644
index 00000000..7ad73312
--- /dev/null
+++ b/frontend/src/components/ui/MatchToggle.dom.test.tsx
@@ -0,0 +1,27 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+
+vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
+
+import MatchToggle from "./MatchToggle";
+
+afterEach(cleanup);
+
+// The any/all switch sits on the panel above its picker (the owner, 2026-09-27): shown from two
+// picks — with one, any and all are the same filter — as one named segment, the current mode pressed.
+describe("MatchToggle", () => {
+ it("draws nothing below two picks", () => {
+ const { container } = render( {}} />);
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("from two picks: a named any/all group with the current mode pressed, reporting the pick", () => {
+ const onChange = vi.fn();
+ render( );
+ const group = screen.getByRole("group", { name: "common.matchMode" });
+ expect(group.querySelector('[aria-pressed="true"]')?.textContent).toBe("common.match.any");
+ fireEvent.click(screen.getByRole("button", { name: "common.match.all" }));
+ expect(onChange).toHaveBeenCalledWith("all");
+ });
+});
diff --git a/frontend/src/components/ui/MatchToggle.tsx b/frontend/src/components/ui/MatchToggle.tsx
new file mode 100644
index 00000000..4e198efc
--- /dev/null
+++ b/frontend/src/components/ui/MatchToggle.tsx
@@ -0,0 +1,40 @@
+import { useTranslation } from "react-i18next";
+import SegmentedToggle from "./SegmentedToggle";
+
+export type MatchMode = "any" | "all";
+
+// The any/all switch of a multi-value filter (tags, topics, languages, genres, collections, actors):
+// a captioned segment on the panel, right ABOVE its picker — never in the picker's popover, where a
+// closed picker hid that "all" was on (the owner, 2026-09-27; DESIGN "Action placement": the state
+// that shapes the results stays in sight). With fewer than two picks any and all are the same filter,
+// so it draws nothing then. The panel's captioned toggle is the one 24px segment (DESIGN log, S3g).
+export default function MatchToggle({
+ picked,
+ value,
+ onChange,
+ className,
+}: {
+ /** How many values the picker holds; the switch shows from two. */
+ picked: number;
+ value: MatchMode;
+ onChange: (mode: MatchMode) => void;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ if (picked < 2) return null;
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json
index bbf438c6..f7e40201 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -34,6 +34,10 @@
},
"moreRefine": "{{count}} more — refine with search",
"matchMode": "Match",
+ "match": {
+ "any": "Any",
+ "all": "All"
+ },
"removeNamed": "Remove {{name}}",
"resetNamed": "Reset {{name}}",
"dnd": {
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json
index ad5882d8..ab172c69 100644
--- a/frontend/src/i18n/locales/en/plex.json
+++ b/frontend/src/i18n/locales/en/plex.json
@@ -92,10 +92,6 @@
"1y": "1 year"
},
"contentRating": "Age rating",
- "match": {
- "any": "Any",
- "all": "All"
- },
"dir": {
"desc": "↓ Descending",
"asc": "↑ Ascending"
@@ -103,7 +99,6 @@
"collection": "Collection",
"collectionSearch": "Search collections…",
"collectionEmpty": "No collections",
- "matchLabel": "Match",
"studio": "Studio",
"playable": "Playback",
"playableOpt": {
diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json
index 57dc5ad8..897bf525 100644
--- a/frontend/src/i18n/locales/en/sidebar.json
+++ b/frontend/src/i18n/locales/en/sidebar.json
@@ -14,8 +14,6 @@
"pin": "Pin panel open",
"unpin": "Unpin panel",
"openPanel": "Open {{title}}",
- "any": "Any",
- "all": "All",
"from": "From",
"to": "To",
"clearDates": "clear dates",
diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json
index 3619dd93..a86084b4 100644
--- a/frontend/src/i18n/locales/hu/common.json
+++ b/frontend/src/i18n/locales/hu/common.json
@@ -34,6 +34,10 @@
},
"moreRefine": "még {{count}} — finomíts kereséssel",
"matchMode": "Egyezés",
+ "match": {
+ "any": "Bármelyik",
+ "all": "Mind"
+ },
"removeNamed": "{{name}}: eltávolítás",
"resetNamed": "{{name}}: alaphelyzet",
"dnd": {
diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json
index 1144d0ad..0f92d39b 100644
--- a/frontend/src/i18n/locales/hu/plex.json
+++ b/frontend/src/i18n/locales/hu/plex.json
@@ -92,10 +92,6 @@
"1y": "1 év"
},
"contentRating": "Korhatár",
- "match": {
- "any": "Bármelyik",
- "all": "Mind"
- },
"dir": {
"desc": "↓ Csökkenő",
"asc": "↑ Növekvő"
@@ -103,7 +99,6 @@
"collection": "Kollekció",
"collectionSearch": "Kollekciók keresése…",
"collectionEmpty": "Nincs kollekció",
- "matchLabel": "Egyezés",
"studio": "Stúdió",
"playable": "Lejátszás",
"playableOpt": {
diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json
index 0e9b58f6..d8825fcc 100644
--- a/frontend/src/i18n/locales/hu/sidebar.json
+++ b/frontend/src/i18n/locales/hu/sidebar.json
@@ -14,8 +14,6 @@
"pin": "Panel rögzítése nyitva",
"unpin": "Panel rögzítésének feloldása",
"openPanel": "{{title}} megnyitása",
- "any": "Bármelyik",
- "all": "Összes",
"from": "Ettől",
"to": "Eddig",
"clearDates": "dátumok törlése",