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 + ); + })} +
+ ); +}