diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index c239802b..f247148c 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -11,6 +11,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import SortControl from "./SortControl";
+import SegmentedToggle from "./SegmentedToggle";
import { usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
@@ -35,8 +36,8 @@ 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). Same segmented-pill look as
-// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
+// 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,
@@ -46,27 +47,16 @@ function MatchToggle({
}) {
const { t } = useTranslation();
return (
-
-
- {t("plex.filter.matchLabel")}
-
-
- {(["any", "all"] as const).map((m) => (
- onChange(m)}
- aria-pressed={mode === m}
- className="pill rounded-full px-2 py-0.5"
- >
- {t(`plex.filter.match.${m}`)}
-
- ))}
-
-
+
);
}
diff --git a/frontend/src/components/SegmentedToggle.dom.test.tsx b/frontend/src/components/SegmentedToggle.dom.test.tsx
new file mode 100644
index 00000000..18394f89
--- /dev/null
+++ b/frontend/src/components/SegmentedToggle.dom.test.tsx
@@ -0,0 +1,72 @@
+// @vitest-environment jsdom
+//
+// Rendering integration of the shared SegmentedToggle. Labels are plain props (no i18n); the active
+// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed contract, not styling.
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import SegmentedToggle from "./SegmentedToggle";
+
+const OPTS = [
+ { value: "or", label: "Any" },
+ { value: "and", label: "All" },
+] as const;
+
+afterEach(cleanup);
+
+describe("SegmentedToggle", () => {
+ it("renders one button per option with the given labels", () => {
+ render( );
+ expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual(["Any", "All"]);
+ });
+
+ it("marks exactly the active option aria-pressed", () => {
+ render( );
+ expect(screen.getByRole("button", { name: "Any" }).getAttribute("aria-pressed")).toBe("false");
+ expect(screen.getByRole("button", { name: "All" }).getAttribute("aria-pressed")).toBe("true");
+ });
+
+ it("clicking an option fires onChange with its value", () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "All" }));
+ expect(onChange).toHaveBeenCalledWith("and");
+ });
+
+ it("exposes the group aria-label", () => {
+ render( );
+ expect(screen.getByRole("group", { name: "Match mode" })).toBeTruthy();
+ });
+
+ it("renders the caption when label is set", () => {
+ render(
+ ,
+ );
+ // caption text plus the two option labels
+ expect(screen.getByText("Match")).toBeTruthy();
+ expect(screen.getAllByRole("button")).toHaveLength(2);
+ });
+
+ it("renders per-option icons and tooltips (scope-bar shape)", () => {
+ render(
+ , title: "Your subs" },
+ { value: "all", label: "Library", icon: },
+ ]}
+ value="my"
+ onChange={vi.fn()}
+ />,
+ );
+ expect(screen.getByTestId("ic-my")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Mine" }).getAttribute("title")).toBe("Your subs");
+ });
+});
diff --git a/frontend/src/components/SegmentedToggle.tsx b/frontend/src/components/SegmentedToggle.tsx
new file mode 100644
index 00000000..728c94d6
--- /dev/null
+++ b/frontend/src/components/SegmentedToggle.tsx
@@ -0,0 +1,75 @@
+import { type ReactNode } from "react";
+
+// Shared MATCH / segmented primitive — a rounded-full pill track where exactly ONE option is active
+// (radio-like). Backs the feed any/all + or/and "Match" toggles and the feed Mine/Library scope bar,
+// and the Plex any/all combine toggles. The active paint comes entirely from the
+// `.pill[aria-pressed="true"]` recipe (index.css) — do NOT add bg/text/border-colour utilities on the
+// buttons here or a utility-layer class would beat the pressed fill.
+
+export interface SegmentedOption {
+ value: T;
+ label: string;
+ icon?: ReactNode;
+ title?: string; // optional per-option tooltip
+}
+
+export default function SegmentedToggle({
+ options,
+ value,
+ onChange,
+ ariaLabel,
+ label,
+ title,
+ fullWidth = false,
+ size = "sm",
+ className,
+}: {
+ options: readonly SegmentedOption[];
+ value: T;
+ onChange: (v: T) => void;
+ ariaLabel: string;
+ // Optional left-hand caption; when set the control renders as a justify-between row (the feed/Plex
+ // "Match" toggles). Omit for a standalone bar (the Mine/Library scope switch).
+ label?: ReactNode;
+ title?: string; // tooltip on the whole row (caption mode only)
+ // Full-width track with flex-1 buttons (the scope bar); default is intrinsic width.
+ fullWidth?: boolean;
+ // sm = text-3xs, compact padding (the captioned match toggles); md = text-xs (the scope bar).
+ size?: "sm" | "md";
+ className?: string;
+}) {
+ const track = (
+
+ {options.map((o) => (
+ onChange(o.value)}
+ title={o.title}
+ aria-pressed={value === o.value}
+ className={`pill inline-flex items-center justify-center gap-1 rounded-full ${
+ size === "md" ? "px-2.5 py-1" : "px-2 py-0.5"
+ }${fullWidth ? " flex-1" : ""}`}
+ >
+ {o.icon}
+ {o.label}
+
+ ))}
+
+ );
+
+ if (label == null) {
+ return className ? {track}
: track;
+ }
+ return (
+
+ {label}
+ {track}
+
+ );
+}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 0eda45a9..203219e1 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -15,6 +15,7 @@ import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
+import SegmentedToggle from "./SegmentedToggle";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
@@ -255,30 +256,17 @@ export default function Sidebar({
const chips = visibleChips(topics);
return (
<>
-
-
- {t("sidebar.match")}
-
-
- {(["or", "and"] as const).map((m) => (
- setFilters({ ...filters, tagMode: m })}
- aria-pressed={filters.tagMode === m}
- className="pill px-2 py-0.5 rounded-full"
- >
- {m === "or" ? t("sidebar.any") : t("sidebar.all")}
-
- ))}
-
-
+ ariaLabel={t("sidebar.match")}
+ options={[
+ { value: "or", label: t("sidebar.any") },
+ { value: "and", label: t("sidebar.all") },
+ ]}
+ value={filters.tagMode}
+ onChange={(tagMode) => setFilters({ ...filters, tagMode })}
+ />
{chips.map((tg) => (
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
-
- {(["my", "all"] as const).map((s) => (
- setFilters({ ...filters, scope: s })}
- title={t("header.scope." + s + "Tip")}
- aria-pressed={filters.scope === s}
- className="pill flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full"
- >
- {s === "my" ? : }
- {t("header.scope." + s)}
-
- ))}
-
+ ,
+ title: t("header.scope.myTip"),
+ },
+ {
+ value: "all",
+ label: t("header.scope.all"),
+ icon: ,
+ title: t("header.scope.allTip"),
+ },
+ ]}
+ value={filters.scope}
+ onChange={(scope) => setFilters({ ...filters, scope })}
+ />