diff --git a/messages/de.json b/messages/de.json
index 9bab26a04..231873ea2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2597,7 +2597,8 @@
"complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.",
"complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage."
},
- "medicationsEmptyAction": "Medikament anlegen"
+ "medicationsEmptyAction": "Medikament anlegen",
+ "rangeLabel": "Zeitraum"
},
"chart": {
"overlay": {
diff --git a/messages/en.json b/messages/en.json
index eb2056a98..1a855b9e2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2597,7 +2597,8 @@
"complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.",
"complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet."
},
- "medicationsEmptyAction": "Add a medication"
+ "medicationsEmptyAction": "Add a medication",
+ "rangeLabel": "Time range"
},
"chart": {
"overlay": {
diff --git a/messages/es.json b/messages/es.json
index 2596d7dd2..6b77db170 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2597,7 +2597,8 @@
"complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.",
"complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos."
},
- "medicationsEmptyAction": "Añadir un medicamento"
+ "medicationsEmptyAction": "Añadir un medicamento",
+ "rangeLabel": "Periodo"
},
"chart": {
"overlay": {
diff --git a/messages/fr.json b/messages/fr.json
index 89593958e..01b82668d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2597,7 +2597,8 @@
"complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.",
"complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant."
},
- "medicationsEmptyAction": "Ajouter un médicament"
+ "medicationsEmptyAction": "Ajouter un médicament",
+ "rangeLabel": "Période"
},
"chart": {
"overlay": {
diff --git a/messages/it.json b/messages/it.json
index 16190ea09..426bf3535 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2597,7 +2597,8 @@
"complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.",
"complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo."
},
- "medicationsEmptyAction": "Aggiungi un farmaco"
+ "medicationsEmptyAction": "Aggiungi un farmaco",
+ "rangeLabel": "Periodo"
},
"chart": {
"overlay": {
diff --git a/messages/ko.json b/messages/ko.json
index 022fde09c..88ba08f68 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -2597,7 +2597,8 @@
"days90Title": "최근 90일",
"daysAllLabel": "전체",
"daysAllTitle": "전체 기간",
- "medicationsEmptyAction": "약 추가하기"
+ "medicationsEmptyAction": "약 추가하기",
+ "rangeLabel": "기간"
},
"chart": {
"overlay": {
diff --git a/messages/nl.json b/messages/nl.json
index 9a519e18a..0a330b827 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -2597,7 +2597,8 @@
"complianceHeatmap": "Heatmap van therapietrouw van {start} tot {end}, {days} actieve dagen.",
"complianceHeatmapEmpty": "Heatmap van therapietrouw, nog geen actieve dagen."
},
- "medicationsEmptyAction": "Medicijn toevoegen"
+ "medicationsEmptyAction": "Medicijn toevoegen",
+ "rangeLabel": "Periode"
},
"chart": {
"overlay": {
diff --git a/messages/pl.json b/messages/pl.json
index 3c2c5f26e..09e13cad4 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2597,7 +2597,8 @@
"complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.",
"complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni."
},
- "medicationsEmptyAction": "Dodaj lek"
+ "medicationsEmptyAction": "Dodaj lek",
+ "rangeLabel": "Zakres czasu"
},
"chart": {
"overlay": {
diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx
index 0281fbd3c..fca1346c5 100644
--- a/src/components/custom-metrics/custom-metric-chart.tsx
+++ b/src/components/custom-metrics/custom-metric-chart.tsx
@@ -13,7 +13,6 @@ import {
YAxis,
} from "recharts";
-import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
@@ -32,6 +31,7 @@ import {
import { dateKeyOfInstant } from "../day/day-url";
import { formatMetricValue } from "./format-value";
import type { CustomMetricEntryDto } from "./types";
+import { Segmented } from "@/components/ui/segmented";
/**
* v1.25.5 — per-custom-metric trend chart, in the dashboard's visual language.
@@ -137,19 +137,17 @@ export function CustomMetricChart({
return (
-
- {RANGE_DAYS.map((r) => (
-
- ))}
+
+ ({
+ value: r.key,
+ label: t(`customMetrics.chart.range.${r.key}`),
+ }))}
+ value={range}
+ onChange={setRange}
+ label={t("charts.rangeLabel")}
+ slot="custom-metric-chart-range"
+ />
{points.length === 0 ? (
diff --git a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
index 208163bcb..4e3a508aa 100644
--- a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
+++ b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
@@ -50,7 +50,6 @@ import { queryKeys } from "@/lib/query-keys";
import { ImportFromSourceButton } from "../import-from-source-button";
import { mergeIds } from "../merge-ids";
-import { nextChoiceIndex } from "../segmented-choice";
import { errorCodeOf, sourceErrorMessage } from "../source-errors";
const PAPERLESS = {
@@ -255,19 +254,6 @@ describe("source error sentences", () => {
});
});
-describe("nextChoiceIndex", () => {
- it("moves with the arrows, wraps, and jumps with Home and End", () => {
- expect(nextChoiceIndex("ArrowRight", 0, 2)).toBe(1);
- expect(nextChoiceIndex("ArrowRight", 1, 2)).toBe(0);
- expect(nextChoiceIndex("ArrowLeft", 0, 2)).toBe(1);
- expect(nextChoiceIndex("ArrowDown", 0, 3)).toBe(1);
- expect(nextChoiceIndex("ArrowUp", 0, 3)).toBe(2);
- expect(nextChoiceIndex("Home", 2, 3)).toBe(0);
- expect(nextChoiceIndex("End", 0, 3)).toBe(2);
- expect(nextChoiceIndex("Enter", 0, 3)).toBeNull();
- });
-});
-
describe("mergeIds", () => {
it("adds imported ids to a form's selection once, in order", () => {
expect(mergeIds(["a", "b"], ["b", "c", "c"])).toEqual(["a", "b", "c"]);
diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx
index 236c430e9..c492f5607 100644
--- a/src/components/documents/sources/document-source-picker.tsx
+++ b/src/components/documents/sources/document-source-picker.tsx
@@ -81,7 +81,7 @@ import {
isStoppingError,
sourceErrorMessage,
} from "./source-errors";
-import { SegmentedChoice } from "./segmented-choice";
+import { Segmented } from "@/components/ui/segmented";
import { systemName } from "./use-document-sources";
import { InlineAction } from "@/components/ui/inline-action";
@@ -409,13 +409,16 @@ export function DocumentSourcePicker({
>
{systems.length > 1 && active ? (
-
({
+ value: system,
+ label: systemName(system),
+ }))}
value={active}
onChange={switchSystem}
label={t("documents.sourcePicker.sourceLabel")}
disabled={running}
- renderOption={systemName}
+ className="self-start"
/>
) : null}
diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx
index 49a537b78..17e924823 100644
--- a/src/components/insights/coach-panel/coach-results.tsx
+++ b/src/components/insights/coach-panel/coach-results.tsx
@@ -35,7 +35,7 @@ import { ChartLine, Table2 } from "lucide-react";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
-import { ViewToggle } from "@/components/ui/view-toggle";
+import { Segmented } from "@/components/ui/segmented";
import { useCoachMessageResults } from "@/hooks/use-coach-message-results";
import {
COACH_RESULT_UI_KEYS,
@@ -286,12 +286,14 @@ function CoachResultView({
}
const toggle = (
-
- view={view}
+
+ mode="toggle"
+ iconOnly
+ value={view}
onChange={setView}
- groupLabel={t(COACH_RESULT_UI_KEYS.viewLabel)}
- dataSlotPrefix="coach-result-view"
- segments={[
+ label={t(COACH_RESULT_UI_KEYS.viewLabel)}
+ optionSlot={(value) => `coach-result-view-${value}`}
+ options={[
{
value: "chart",
label: t(COACH_RESULT_UI_KEYS.viewChart),
diff --git a/src/components/insights/mood/mood-dimension-trends.tsx b/src/components/insights/mood/mood-dimension-trends.tsx
index 52a860719..a628ddb7e 100644
--- a/src/components/insights/mood/mood-dimension-trends.tsx
+++ b/src/components/insights/mood/mood-dimension-trends.tsx
@@ -30,6 +30,7 @@ import {
type MoodDimensionWindow,
} from "@/lib/mood/dimensions";
import { cn } from "@/lib/utils";
+import { Segmented } from "@/components/ui/segmented";
/**
* v1.37 — the five level-A dimensions over time.
@@ -136,24 +137,20 @@ export function MoodDimensionTrends({
return (
-
- {MOOD_DIMENSION_WINDOWS.map((option) => (
-
- ))}
-
+
({
+ value: String(option),
+ label: t("insights.mood.dimensions.window", { days: option }),
+ }))}
+ value={String(window)}
+ onChange={(next) =>
+ setWindow(
+ MOOD_DIMENSION_WINDOWS.find((w) => String(w) === next) ?? window,
+ )
+ }
+ label={t("charts.rangeLabel")}
+ slot="mood-dimension-window"
+ />
0 ? (
-
-
{historyView === "recent" ? (
diff --git a/src/components/medications/medication-view-toggle.tsx b/src/components/medications/medication-view-toggle.tsx
index 5f32c0e08..25b21cb16 100644
--- a/src/components/medications/medication-view-toggle.tsx
+++ b/src/components/medications/medication-view-toggle.tsx
@@ -3,12 +3,12 @@
import { LayoutGrid, Table as TableIcon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
-import { ViewToggle } from "@/components/ui/view-toggle";
+import { Segmented } from "@/components/ui/segmented";
import type { MedicationListView } from "@/lib/medication-list-layout";
/**
* v1.16.10 — icon-only card/table view toggle for the /medications page
- * header. Thin wrapper over the shared `ViewToggle` primitive; see its
+ * header. Thin wrapper over the shared `Segmented` primitive (icon-only, toggle mode); see its
* docstring for the interaction grammar.
*/
interface MedicationViewToggleProps {
@@ -23,12 +23,14 @@ export function MedicationViewToggle({
const { t } = useTranslations();
return (
- `medications-view-${value}`}
+ options={[
{
value: "cards",
label: t("medications.viewCards"),
diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx
index fbbc61941..72ffab347 100644
--- a/src/components/medications/sections/inventory-dialogs.tsx
+++ b/src/components/medications/sections/inventory-dialogs.tsx
@@ -47,6 +47,7 @@ import {
type ContainerType,
type InventoryItem,
} from "./inventory-shared";
+import { Segmented } from "@/components/ui/segmented";
/**
* Both dates the row carries are entered — and were always stored — as a
@@ -219,33 +220,23 @@ export function AddInventoryDialog({
{t("medications.detail.bestand.addQuantityLabel")}
{unitsPerDose > 1 && (
-
-
-
-
+
)}
`module-view-${value}`}
+ options={[
{
value: "cards",
label: t("moduleList.viewCards"),
diff --git a/src/components/settings/ai/ai-usage-card.tsx b/src/components/settings/ai/ai-usage-card.tsx
index 6e1bd32fd..b3002b48b 100644
--- a/src/components/settings/ai/ai-usage-card.tsx
+++ b/src/components/settings/ai/ai-usage-card.tsx
@@ -20,7 +20,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Gauge } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { nextChoiceIndex } from "@/components/documents/sources/segmented-choice";
+import { rovingRadioNextIndex } from "@/hooks/use-roving-radio-group";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -94,7 +94,7 @@ export function AiUsageCard() {
level: AiUsageLevel,
) {
const from = allowedLevels.indexOf(level);
- const next = nextChoiceIndex(event.key, from, allowedLevels.length);
+ const next = rovingRadioNextIndex(event.key, from, allowedLevels.length);
if (next === null) return;
event.preventDefault();
const target = allowedLevels[next]!;
diff --git a/src/components/settings/documents-section.tsx b/src/components/settings/documents-section.tsx
index c5539c8db..4b8cd081b 100644
--- a/src/components/settings/documents-section.tsx
+++ b/src/components/settings/documents-section.tsx
@@ -14,7 +14,7 @@ import { LayoutGrid, Rows3, WrapText } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
-import { ViewToggle } from "@/components/ui/view-toggle";
+import { Segmented } from "@/components/ui/segmented";
import { useTranslations } from "@/lib/i18n/context";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
@@ -39,12 +39,14 @@ export function DocumentsSection() {
title={t("documents.layout.arrangementHeading")}
description={t("documents.layout.arrangementDescription")}
status={
- `documents-arrangement-${value}`}
+ options={[
{
value: "stacked",
label: t("documents.layout.stacked"),
diff --git a/src/components/settings/integrations/document-sources-card.tsx b/src/components/settings/integrations/document-sources-card.tsx
index 8a0cf216e..b942a92a5 100644
--- a/src/components/settings/integrations/document-sources-card.tsx
+++ b/src/components/settings/integrations/document-sources-card.tsx
@@ -39,7 +39,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
-import { SegmentedChoice } from "@/components/documents/sources/segmented-choice";
+import { Segmented } from "@/components/ui/segmented";
import {
errorCodeOf,
sourceErrorMessage,
@@ -104,23 +104,22 @@ export function DocumentSourcesCard() {
{options.length > 1 ? (
- ({
+ value: option,
+ label: systemName(option),
+ trailing: connections.some((c) => c.system === option) ? (
+
+ ) : null,
+ }))}
value={system}
onChange={setPicked}
label={t("settings.documentSources.systemLabel")}
slot="document-sources-system"
- renderOption={(option) => (
- <>
- {systemName(option)}
- {connections.some((c) => c.system === option) ? (
-
- ) : null}
- >
- )}
+ className="self-start"
/>
) : null}
diff --git a/src/components/timeline/life-event-form.tsx b/src/components/timeline/life-event-form.tsx
index e329b5930..90603215e 100644
--- a/src/components/timeline/life-event-form.tsx
+++ b/src/components/timeline/life-event-form.tsx
@@ -52,7 +52,7 @@ import {
LIFE_EVENT_ERROR_KEY,
LIFE_EVENT_PRECISION_KEY,
} from "./label-keys";
-import { Segmented } from "./segmented";
+import { Segmented } from "@/components/ui/segmented";
import { formatMonthLong, todayKeyIn } from "./timeline-dates";
import { useLifeEventMutations } from "./use-timeline";
@@ -393,7 +393,7 @@ export function LifeEventForm({
label={t("lifeEvents.fields.precision")}
slot="life-event-precision"
stretch
- asToggleButtons
+ mode="toggle"
/>
diff --git a/src/components/timeline/range-picker.tsx b/src/components/timeline/range-picker.tsx
index 69063c358..1a33c4f33 100644
--- a/src/components/timeline/range-picker.tsx
+++ b/src/components/timeline/range-picker.tsx
@@ -22,7 +22,7 @@ import type { TimelineZoom } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { RangeFields } from "./range-fields";
-import { Segmented, type SegmentedOption } from "./segmented";
+import { Segmented, type SegmentedOption } from "@/components/ui/segmented";
import type { TimelineRange } from "./timeline-url";
interface RangeLimits {
diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx
index 0f6925265..86dc117e9 100644
--- a/src/components/timeline/timeline-view.tsx
+++ b/src/components/timeline/timeline-view.tsx
@@ -59,7 +59,7 @@ import {
showReadinessCard,
} from "./readiness-model";
import { TimelineReadinessSheet } from "./readiness-sheet";
-import { Segmented } from "./segmented";
+import { Segmented } from "@/components/ui/segmented";
import { SERIES_PALETTE, assignSeriesColors } from "./series-colors";
import { useSeriesValueFormat } from "./use-series-value-format";
import { SelectionBar } from "./selection-bar";
diff --git a/src/components/ui/__tests__/segmented.test.tsx b/src/components/ui/__tests__/segmented.test.tsx
new file mode 100644
index 000000000..b3fef660b
--- /dev/null
+++ b/src/components/ui/__tests__/segmented.test.tsx
@@ -0,0 +1,139 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { isValidElement, type ReactElement, type ReactNode } from "react";
+import { LayoutGrid, Table } from "lucide-react";
+
+import { Segmented, type SegmentedProps } from "../segmented";
+
+const OPTIONS = [
+ { value: "recent", label: "Last 90 days" },
+ { value: "all", label: "All" },
+] as const;
+
+type Props = SegmentedProps<"recent" | "all">;
+
+function html(props: Partial = {}) {
+ return renderToStaticMarkup(
+ {}}
+ label="History"
+ slot="history-view"
+ {...props}
+ />,
+ );
+}
+
+/** The segment buttons of a rendered tree, for driving their handlers. */
+function buttons(node: ReactNode): ReactElement<{ onClick: () => void }>[] {
+ const out: ReactElement<{ onClick: () => void }>[] = [];
+ const walk = (n: ReactNode) => {
+ if (Array.isArray(n)) return n.forEach(walk);
+ if (!isValidElement(n)) return;
+ const el = n as ReactElement<{ children?: ReactNode; onClick: () => void }>;
+ if (el.type === "button") out.push(el);
+ walk(el.props.children);
+ };
+ walk(node);
+ return out;
+}
+
+vi.mock("@/hooks/use-roving-radio-group", () => ({
+ useRovingRadioGroup: () => ({
+ getRadioProps: (i: number) => ({ tabIndex: i === 0 ? 0 : -1 }),
+ }),
+}));
+
+describe("", () => {
+ it("is a radiogroup with one checked radio by default", () => {
+ const out = html();
+ expect(out).toContain('role="radiogroup"');
+ expect(out).toContain('aria-label="History"');
+ expect(out.match(/role="radio"/g)?.length).toBe(2);
+ expect(out.match(/aria-checked="true"/g)?.length).toBe(1);
+ expect(out).toContain('data-slot="history-view-all"');
+ });
+
+ it("gives every segment the 44 px floor on a coarse pointer", () => {
+ const out = html();
+ expect(out.match(/pointer-coarse:min-h-11/g)?.length).toBe(2);
+ expect(out).toContain("min-h-9");
+ });
+
+ it("renders pressed buttons in toggle mode", () => {
+ const out = html({ mode: "toggle" });
+ expect(out).toContain('role="group"');
+ expect(out).not.toContain('role="radio"');
+ expect(out).toContain('aria-pressed="true"');
+ expect(out).toContain('aria-pressed="false"');
+ });
+
+ it("names icon-only segments and sizes them square", () => {
+ const out = renderToStaticMarkup(
+ {}}
+ label="View"
+ optionSlot={(v) => `medications-view-${v}`}
+ />,
+ );
+ expect(out).toContain('aria-label="Cards"');
+ expect(out).toContain('title="Table"');
+ expect(out.match(/pointer-coarse:size-11/g)?.length).toBe(2);
+ expect(out).toContain('data-slot="medications-view-table"');
+ expect(out).not.toContain(">Cards<");
+ });
+
+ it("re-fires the chosen segment in radio mode, which can open something", () => {
+ const onChange = vi.fn();
+ const tree = Segmented({
+ options: OPTIONS,
+ value: "recent",
+ onChange,
+ label: "History",
+ });
+ buttons(tree)[0].props.onClick();
+ expect(onChange).toHaveBeenCalledWith("recent");
+ });
+
+ it("ignores a press on the active segment in toggle mode", () => {
+ const onChange = vi.fn();
+ const tree = Segmented({
+ options: OPTIONS,
+ value: "recent",
+ onChange,
+ label: "History",
+ mode: "toggle",
+ });
+ const [active, other] = buttons(tree);
+ active.props.onClick();
+ expect(onChange).not.toHaveBeenCalled();
+ other.props.onClick();
+ expect(onChange).toHaveBeenCalledWith("all");
+ });
+});
+
+describe("one segmented control", () => {
+ it("is the only segmented-control component in the tree", async () => {
+ const { readFileSync } = await import("node:fs");
+ const { join } = await import("node:path");
+ const { walkSourceFiles } =
+ await import("@/__tests__/helpers/source-files");
+ const src = join(__dirname, "../../..");
+ const defining = walkSourceFiles(src, { floor: 2000 })
+ .filter((p) => !p.includes("__tests__/"))
+ .filter((p) =>
+ /export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test(
+ readFileSync(join(src, p), "utf8"),
+ ),
+ );
+ expect(defining).toEqual(["components/ui/segmented.tsx"]);
+ });
+});
diff --git a/src/components/ui/segmented.tsx b/src/components/ui/segmented.tsx
new file mode 100644
index 000000000..786ccb026
--- /dev/null
+++ b/src/components/ui/segmented.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+/**
+ * The segmented control: a short row of mutually exclusive choices on one
+ * track (zoom level, chronicle grouping, cards or table, recent or all,
+ * Paperless-ngx or Papra). One look everywhere: a muted track, the chosen
+ * segment raised on the background colour, never coloured.
+ *
+ * Two semantics, one chrome:
+ *
+ * - `mode="radio"` (default): an ARIA radiogroup with one tab stop and arrow
+ * keys through the shared roving helper. `onChange` fires on every press,
+ * the chosen segment included, because a segment can open something (the
+ * timeline's custom range re-opens its popover).
+ * - `mode="toggle"`: buttons with `aria-pressed`, for a view switch and for a
+ * choice inside a form sheet (the sheets' discard guard reads any checked
+ * radio as the person's input, and a precision that starts on "Day" is a
+ * default, not an answer). Pressing the active segment does nothing, so a
+ * setter that saves a preference is not called twice.
+ *
+ * Touch: every segment is at least 44 px on a coarse pointer and 36 px under
+ * a mouse, the same floor `Button` carries. The four controls this replaced
+ * sat at 36, 36, 44 and 44.
+ *
+ * An option with an `icon` and `iconOnly` on the control renders the icon and
+ * names the segment through `aria-label` and `title`.
+ */
+import { Fragment, type ReactElement, type ReactNode } from "react";
+import type { LucideIcon } from "lucide-react";
+
+import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
+import { cn } from "@/lib/utils";
+
+export interface SegmentedOption {
+ value: T;
+ label: string;
+ icon?: LucideIcon;
+ /** Rendered after the label (a connected check beside a system name). */
+ trailing?: ReactNode;
+}
+
+export interface SegmentedProps {
+ options: ReadonlyArray>;
+ value: T;
+ onChange: (next: T) => void;
+ /** Accessible name of the group. */
+ label: string;
+ /** `data-slot` of the group. */
+ slot?: string;
+ /**
+ * `data-slot` of one segment. Defaults to `${slot}-${value}` when the group
+ * has a slot.
+ */
+ optionSlot?: (value: T) => string;
+ mode?: "radio" | "toggle";
+ /** Render the options' icons only; the label becomes the accessible name. */
+ iconOnly?: boolean;
+ /** Spread the segments over the full width. */
+ stretch?: boolean;
+ disabled?: boolean;
+ className?: string;
+ /**
+ * Wraps one segment's button, for a popover anchored to it (the range
+ * segment). Returns the button itself for every other segment.
+ */
+ wrapOption?: (value: T, button: ReactElement) => ReactNode;
+}
+
+export function Segmented({
+ options,
+ value,
+ onChange,
+ label,
+ slot,
+ optionSlot,
+ mode = "radio",
+ iconOnly = false,
+ stretch = false,
+ disabled = false,
+ className,
+ wrapOption,
+}: SegmentedProps) {
+ const selectedIndex = options.findIndex((o) => o.value === value);
+ const { getRadioProps } = useRovingRadioGroup({
+ count: options.length,
+ selectedIndex,
+ onSelect: (index) => {
+ if (!disabled) onChange(options[index].value);
+ },
+ });
+ const segmentSlot =
+ optionSlot ?? (slot ? (v: T) => `${slot}-${v}` : undefined);
+
+ return (
+
+ {options.map((option, index) => {
+ const checked = option.value === value;
+ const Icon = option.icon;
+ const button = (
+
+ );
+ return wrapOption ? (
+
+ {wrapOption(option.value, button)}
+
+ ) : (
+ button
+ );
+ })}
+
+ );
+}