refactor(ui): one Segmented control instead of four

The timeline's Segmented, the document sources' SegmentedChoice, the
medication detail's SegmentedToggle and the icon-only ViewToggle were
four implementations of the same control in two looks, two at 36 px on
a phone. ui/segmented.tsx replaces all four, plus the hand-rolled range
rows above the lab and custom metric charts, the mood dimension window
and the inventory quantity mode: a muted track with the chosen segment
raised, radio semantics with roving focus by default, pressed-button
semantics for view switches and form sheets, icon-only segments, and
44 px per segment on a coarse pointer. A test holds the definition to
that one module.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:15:29 +02:00
1 parent c849619bee
commit ab3f21c58c
26 files changed
+428 -139

No files matched your search

+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -2597,7 +2597,8 @@
"days90Title": "최근 90일",
"daysAllLabel": "전체",
"daysAllTitle": "전체 기간",
"medicationsEmptyAction": "약 추가하기"
"medicationsEmptyAction": "약 추가하기",
"rangeLabel": "기간"
},
"chart": {
"overlay": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
@@ -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 (
<div className="space-y-3">
<div className="flex justify-end gap-1">
{RANGE_DAYS.map((r) => (
<Button
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
aria-pressed={range === r.key}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
{t(`customMetrics.chart.range.${r.key}`)}
</Button>
))}
<div className="flex justify-end">
<Segmented
options={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"
/>
</div>
{points.length === 0 ? (
@@ -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"]);
@@ -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({
>
<div className="space-y-3" data-slot="document-source-picker">
{systems.length > 1 && active ? (
<SegmentedChoice
options={systems}
<Segmented
options={systems.map((system) => ({
value: system,
label: systemName(system),
}))}
value={active}
onChange={switchSystem}
label={t("documents.sourcePicker.sourceLabel")}
disabled={running}
renderOption={systemName}
className="self-start"
/>
) : null}
@@ -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 = (
<ViewToggle<ResultView>
view={view}
<Segmented<ResultView>
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),
@@ -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 (
<div className="space-y-3" data-slot="mood-dimension-trends">
<div className="flex flex-wrap items-center gap-1.5">
{MOOD_DIMENSION_WINDOWS.map((option) => (
<button
key={option}
type="button"
onClick={() => setWindow(option)}
aria-pressed={window === option}
className={cn(
"min-h-9 rounded-full border px-3 text-xs transition-colors",
window === option
? "border-primary bg-primary/15 text-primary"
: "border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{t("insights.mood.dimensions.window", { days: option })}
</button>
))}
</div>
<Segmented
options={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"
/>
<div
data-slot="chart-plot"
@@ -16,7 +16,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
import { Segmented } from "@/components/ui/segmented";
import { TileHeader } from "@/components/insights/tile-header";
import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
@@ -258,7 +258,8 @@ function TargetCard({
{target.series.length > 0 ? (
<div className="space-y-3">
<div className="flex justify-end">
<SegmentedToggle
<Segmented
mode="toggle"
value={scaleMode}
options={[
{
@@ -272,8 +273,8 @@ function TargetCard({
},
]}
onChange={setScaleMode}
ariaLabel={t("medications.efficacy.scaleToggleLabel")}
dataSlot="medication-wirkung-scale-toggle"
label={t("medications.efficacy.scaleToggleLabel")}
slot="medication-wirkung-scale-toggle"
/>
</div>
<EfficacyChart
@@ -65,7 +65,7 @@ import {
} from "@/components/medications/card-parts/medication-compliance-bars";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
import { Segmented } from "@/components/ui/segmented";
import { ScheduleTimesEditor } from "@/components/medications/scheduling/schedule-times-editor";
import { ScheduleHistoryTimeline } from "@/components/medications/scheduling/schedule-history-timeline";
import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window";
@@ -855,7 +855,8 @@ export function MedicationDetailTabs({
Daten (the DataPortabilityRow), not in this header. */}
<TabsContent value="verlauf" className="space-y-6 pt-2">
<div className="flex justify-end">
<SegmentedToggle
<Segmented
mode="toggle"
value={historyView}
options={[
{
@@ -868,8 +869,8 @@ export function MedicationDetailTabs({
},
]}
onChange={setHistoryView}
ariaLabel={t("medications.detail.intake.viewToggleLabel")}
dataSlot="medication-verlauf-view-toggle"
label={t("medications.detail.intake.viewToggleLabel")}
slot="medication-verlauf-view-toggle"
/>
</div>
{historyView === "recent" ? (
@@ -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 (
<ViewToggle
view={view}
<Segmented
mode="toggle"
iconOnly
value={view}
onChange={onChange}
groupLabel={t("medications.viewToggleLabel")}
dataSlotPrefix="medications-view"
segments={[
label={t("medications.viewToggleLabel")}
optionSlot={(value) => `medications-view-${value}`}
options={[
{
value: "cards",
label: t("medications.viewCards"),
@@ -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")}
</label>
{unitsPerDose > 1 && (
<div
className="border-border/60 inline-flex rounded-md border p-0.5"
role="group"
aria-label={t("medications.detail.bestand.addQuantityLabel")}
data-slot="inventory-quantity-mode"
>
<Button
type="button"
size="sm"
variant={quantityMode === "doses" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "doses"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("doses")}
>
{t("medications.detail.bestand.quantityModeDoses")}
</Button>
<Button
type="button"
size="sm"
variant={quantityMode === "units" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "units"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("units")}
>
{t("medications.detail.bestand.quantityModeUnits")}
</Button>
</div>
<Segmented
mode="toggle"
options={[
{
value: "doses",
label: t("medications.detail.bestand.quantityModeDoses"),
},
{
value: "units",
label: t("medications.detail.bestand.quantityModeUnits"),
},
]}
value={quantityMode}
onChange={setQuantityMode}
label={t("medications.detail.bestand.addQuantityLabel")}
slot="inventory-quantity-mode"
/>
)}
<Input
id="inventory-add-quantity"
@@ -3,13 +3,13 @@
import { LayoutGrid, List as ListIcon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { ViewToggle } from "@/components/ui/view-toggle";
import { Segmented } from "@/components/ui/segmented";
import type { ModuleListView } from "@/lib/module-list-prefs";
/**
* v1.18.6 — icon-only card/list view toggle shared by the
* Vorsorge / Illness / Labs settings pages. Thin wrapper over the shared
* `ViewToggle` primitive (mirrors the medication module's
* `Segmented` primitive, icon-only (mirrors the medication module's
* `MedicationViewToggle`, which is the same wrapper switching between a
* card grid and a compact list rather than cards-vs-table).
*/
@@ -22,12 +22,14 @@ export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) {
const { t } = useTranslations();
return (
<ViewToggle
view={view}
<Segmented
mode="toggle"
iconOnly
value={view}
onChange={onChange}
groupLabel={t("moduleList.viewToggleLabel")}
dataSlotPrefix="module-view"
segments={[
label={t("moduleList.viewToggleLabel")}
optionSlot={(value) => `module-view-${value}`}
options={[
{
value: "cards",
label: t("moduleList.viewCards"),
+2 -2
View File
@@ -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]!;
@@ -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={
<ViewToggle
view={layout.arrangement}
<Segmented
mode="toggle"
iconOnly
value={layout.arrangement}
onChange={setArrangement}
groupLabel={t("documents.layout.arrangementHeading")}
dataSlotPrefix="documents-arrangement"
segments={[
label={t("documents.layout.arrangementHeading")}
optionSlot={(value) => `documents-arrangement-${value}`}
options={[
{
value: "stacked",
label: t("documents.layout.stacked"),
@@ -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() {
</p>
{options.length > 1 ? (
<SegmentedChoice
options={options}
<Segmented
options={options.map((option) => ({
value: option,
label: systemName(option),
trailing: connections.some((c) => c.system === option) ? (
<Check
className="size-3.5"
aria-label={t("settings.documentSources.statusConnected")}
/>
) : null,
}))}
value={system}
onChange={setPicked}
label={t("settings.documentSources.systemLabel")}
slot="document-sources-system"
renderOption={(option) => (
<>
{systemName(option)}
{connections.some((c) => c.system === option) ? (
<Check
className="size-3.5"
aria-label={t("settings.documentSources.statusConnected")}
/>
) : null}
</>
)}
className="self-start"
/>
) : null}
+2 -2
View File
@@ -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"
/>
</div>
</div>
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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";
@@ -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<Props> = {}) {
return renderToStaticMarkup(
<Segmented
options={OPTIONS}
value="recent"
onChange={() => {}}
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("<Segmented>", () => {
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(
<Segmented
mode="toggle"
iconOnly
options={[
{ value: "cards", label: "Cards", icon: LayoutGrid },
{ value: "table", label: "Table", icon: Table },
]}
value="cards"
onChange={() => {}}
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"]);
});
});
+158
View File
@@ -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<T extends string> {
value: T;
label: string;
icon?: LucideIcon;
/** Rendered after the label (a connected check beside a system name). */
trailing?: ReactNode;
}
export interface SegmentedProps<T extends string> {
options: ReadonlyArray<SegmentedOption<T>>;
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<T extends string>({
options,
value,
onChange,
label,
slot,
optionSlot,
mode = "radio",
iconOnly = false,
stretch = false,
disabled = false,
className,
wrapOption,
}: SegmentedProps<T>) {
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 (
<div
role={mode === "toggle" ? "group" : "radiogroup"}
aria-label={label}
aria-disabled={disabled || undefined}
data-slot={slot}
className={cn(
"bg-muted inline-flex shrink-0 items-center gap-0.5 rounded-lg p-0.5",
stretch && "flex w-full",
className,
)}
>
{options.map((option, index) => {
const checked = option.value === value;
const Icon = option.icon;
const button = (
<button
key={option.value}
type="button"
{...(mode === "toggle"
? { "aria-pressed": checked }
: {
role: "radio",
"aria-checked": checked,
...getRadioProps(index),
})}
{...(iconOnly
? { "aria-label": option.label, title: option.label }
: {})}
disabled={disabled}
data-value={option.value}
data-slot={segmentSlot?.(option.value)}
onClick={() => {
if (mode === "toggle" && checked) return;
onChange(option.value);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
iconOnly
? "size-9 pointer-coarse:size-11"
: "min-h-9 px-3 pointer-coarse:min-h-11",
stretch && "flex-1",
checked
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{Icon ? (
<Icon className="size-4 shrink-0" aria-hidden="true" />
) : null}
{iconOnly ? null : option.label}
{iconOnly ? null : option.trailing}
</button>
);
return wrapOption ? (
<Fragment key={option.value}>
{wrapOption(option.value, button)}
</Fragment>
) : (
button
);
})}
</div>
);
}