Files
siftlode/frontend/src/components/ui/SegmentedToggle.tsx
T
peter e301632765 fix(clip): a clip refusal speaks the user's language, and a choice in the clip form no longer sends it
The backend refuses a clip with a structured reason, but the client showed every one as the generic
"couldn't make the clip": each reason now has its own message in both languages, held to the
backend's reasons in both directions by the contract test. A segmented toggle's and the priority
stepper's buttons had no type, so inside a form one of them submitted it.
2026-10-10 12:52:12 +02:00

93 lines
3.4 KiB
TypeScript

import { useId, 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 = "md",
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;
// What the choice means, beyond its name: the row's tooltip for the pointer, and the group's
// accessible description for the keyboard and a screen reader — a `title` on a non-focusable box
// alone reaches neither (it had only ever been the pointer's).
title?: string;
// Full-width track with flex-1 buttons (the scope bar); default is intrinsic width.
fullWidth?: boolean;
// md (28, text-xs) is the toolbar rung: it lines up with the search pill, the triggers and an `sm`
// Button (DESIGN B1). sm (24, text-3xs) is only a side panel's captioned match toggle. md is the
// default because the old sm default slid a pager and a toolbar count onto 10px type.
size?: "sm" | "md";
className?: string;
}) {
const hintId = useId();
const track = (
<div
className={`inline-flex items-center rounded-full border border-border bg-card p-px ${
size === "md" ? "text-xs" : "text-3xs"
}${fullWidth ? " w-full" : ""}`}
role="group"
aria-label={ariaLabel}
aria-describedby={title ? hintId : undefined}
title={label == null ? title : undefined}
>
{title && (
<span id={hintId} hidden>
{title}
</span>
)}
{options.map((o) => (
<button
key={o.value}
// A choice, never a submit: inside a form (the clip panel) a bare button would send it.
type="button"
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={value === o.value}
// A segment is the target: 24 (sm) / 28 (md), so the track lands on the ladder's 28 / 32
// with its 1px padding and edge (B1); 44 on a coarse pointer (A3).
className={`pill touch-target inline-flex items-center justify-center gap-1 rounded-full ${
size === "md" ? "h-7 px-2.5" : "h-6 px-2"
}${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>
);
}