From 61e848e63a6dfbc785f7c2ce73b09933c170e644 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 14 Aug 2026 22:58:42 +0200 Subject: [PATCH] refactor(ui): shared SegmentedToggle for MATCH controls (R13 S2c step 1) New source-agnostic SegmentedToggle primitive (rounded-full pill track, one active option, aria-pressed drives the paint via the .pill recipe). Migrates the three hand-rolled MATCH segments onto it: - feed tag any/all "Match" toggle (Sidebar) - feed Mine/Library scope bar (Sidebar; fullWidth + icons + per-option tips) - Plex any/all combine toggle (PlexSidebar MatchToggle is now a thin binding) Caption mode (justify-between + label) covers the match toggles; fullWidth + size="md" covers the scope bar. Adds SegmentedToggle.dom.test.tsx (6 tests). Part of the app-wide single-select control unification; branch not merged. --- frontend/src/components/PlexSidebar.tsx | 36 ++++----- .../components/SegmentedToggle.dom.test.tsx | 72 ++++++++++++++++++ frontend/src/components/SegmentedToggle.tsx | 75 +++++++++++++++++++ frontend/src/components/Sidebar.tsx | 73 ++++++++---------- 4 files changed, 192 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/SegmentedToggle.dom.test.tsx create mode 100644 frontend/src/components/SegmentedToggle.tsx 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) => ( - - ))} -
-
+ ); } 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) => ( + + ))} +
+ ); + + 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) => ( - - ))} -
-
+ 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) => ( - - ))} -
+ , + 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 })} + />