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.
This commit is contained in:
@@ -11,6 +11,7 @@ import { useScrollFade } from "../lib/useScrollFade";
|
|||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
import SortControl from "./SortControl";
|
import SortControl from "./SortControl";
|
||||||
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
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 },
|
{ 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 any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared
|
||||||
// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
|
// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle).
|
||||||
function MatchToggle({
|
function MatchToggle({
|
||||||
mode,
|
mode,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -46,27 +47,16 @@ function MatchToggle({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
<SegmentedToggle
|
||||||
<span className="text-3xs uppercase tracking-wide text-muted">
|
label={t("plex.filter.matchLabel")}
|
||||||
{t("plex.filter.matchLabel")}
|
ariaLabel={t("plex.filter.matchLabel")}
|
||||||
</span>
|
options={[
|
||||||
<div
|
{ value: "any", label: t("plex.filter.match.any") },
|
||||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
|
{ value: "all", label: t("plex.filter.match.all") },
|
||||||
role="group"
|
]}
|
||||||
aria-label={t("plex.filter.matchLabel")}
|
value={mode}
|
||||||
>
|
onChange={onChange}
|
||||||
{(["any", "all"] as const).map((m) => (
|
/>
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
onClick={() => onChange(m)}
|
|
||||||
aria-pressed={mode === m}
|
|
||||||
className="pill rounded-full px-2 py-0.5"
|
|
||||||
>
|
|
||||||
{t(`plex.filter.match.${m}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(<SegmentedToggle options={OPTS} value="or" onChange={vi.fn()} ariaLabel="Match" />);
|
||||||
|
expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual(["Any", "All"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks exactly the active option aria-pressed", () => {
|
||||||
|
render(<SegmentedToggle options={OPTS} value="and" onChange={vi.fn()} ariaLabel="Match" />);
|
||||||
|
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(<SegmentedToggle options={OPTS} value="or" onChange={onChange} ariaLabel="Match" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "All" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("and");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the group aria-label", () => {
|
||||||
|
render(<SegmentedToggle options={OPTS} value="or" onChange={vi.fn()} ariaLabel="Match mode" />);
|
||||||
|
expect(screen.getByRole("group", { name: "Match mode" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the caption when label is set", () => {
|
||||||
|
render(
|
||||||
|
<SegmentedToggle
|
||||||
|
options={OPTS}
|
||||||
|
value="or"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
ariaLabel="Match"
|
||||||
|
label="Match"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<SegmentedToggle
|
||||||
|
ariaLabel="Scope"
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
options={[
|
||||||
|
{ value: "my", label: "Mine", icon: <svg data-testid="ic-my" />, title: "Your subs" },
|
||||||
|
{ value: "all", label: "Library", icon: <svg data-testid="ic-all" /> },
|
||||||
|
]}
|
||||||
|
value="my"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("ic-my")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Mine" }).getAttribute("title")).toBe("Your subs");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
title?: string; // optional per-option tooltip
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SegmentedToggle<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
fullWidth = false,
|
||||||
|
size = "sm",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
options: readonly SegmentedOption<T>[];
|
||||||
|
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 = (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center rounded-full border border-border bg-card p-0.5 ${
|
||||||
|
size === "md" ? "text-xs" : "text-3xs"
|
||||||
|
}${fullWidth ? " w-full" : ""}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => 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}
|
||||||
|
<span>{o.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (label == null) {
|
||||||
|
return className ? <div className={className}>{track}</div> : track;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between ${className ?? "mb-1.5"}`} title={title}>
|
||||||
|
<span className="text-3xs uppercase tracking-wide text-muted">{label}</span>
|
||||||
|
{track}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import SavedViewsWidget from "./SavedViewsWidget";
|
|||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { useChannelsActions } from "./ChannelsProvider";
|
import { useChannelsActions } from "./ChannelsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
@@ -255,30 +256,17 @@ export default function Sidebar({
|
|||||||
const chips = visibleChips(topics);
|
const chips = visibleChips(topics);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<SegmentedToggle
|
||||||
className="flex items-center justify-between mb-1.5"
|
label={t("sidebar.match")}
|
||||||
title={t("sidebar.tagModeTooltip")}
|
title={t("sidebar.tagModeTooltip")}
|
||||||
>
|
ariaLabel={t("sidebar.match")}
|
||||||
<span className="text-3xs uppercase tracking-wide text-muted">
|
options={[
|
||||||
{t("sidebar.match")}
|
{ value: "or", label: t("sidebar.any") },
|
||||||
</span>
|
{ value: "and", label: t("sidebar.all") },
|
||||||
<div
|
]}
|
||||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
|
value={filters.tagMode}
|
||||||
role="group"
|
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
|
||||||
aria-label={t("sidebar.match")}
|
/>
|
||||||
>
|
|
||||||
{(["or", "and"] as const).map((m) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
onClick={() => 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")}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{chips.map((tg) => (
|
{chips.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
@@ -368,24 +356,27 @@ export default function Sidebar({
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
|
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
|
||||||
<div
|
<SegmentedToggle
|
||||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
|
ariaLabel={t("header.scope.label")}
|
||||||
role="group"
|
fullWidth
|
||||||
aria-label={t("header.scope.label")}
|
size="md"
|
||||||
>
|
options={[
|
||||||
{(["my", "all"] as const).map((s) => (
|
{
|
||||||
<button
|
value: "my",
|
||||||
key={s}
|
label: t("header.scope.my"),
|
||||||
onClick={() => setFilters({ ...filters, scope: s })}
|
icon: <User className="w-3.5 h-3.5" />,
|
||||||
title={t("header.scope." + s + "Tip")}
|
title: t("header.scope.myTip"),
|
||||||
aria-pressed={filters.scope === s}
|
},
|
||||||
className="pill flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full"
|
{
|
||||||
>
|
value: "all",
|
||||||
{s === "my" ? <User className="w-3.5 h-3.5" /> : <Library className="w-3.5 h-3.5" />}
|
label: t("header.scope.all"),
|
||||||
<span>{t("header.scope." + s)}</span>
|
icon: <Library className="w-3.5 h-3.5" />,
|
||||||
</button>
|
title: t("header.scope.allTip"),
|
||||||
))}
|
},
|
||||||
</div>
|
]}
|
||||||
|
value={filters.scope}
|
||||||
|
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||||
|
/>
|
||||||
|
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user