mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
c849619bee
commit
ab3f21c58c
26 files changed
+428
-139
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -2597,7 +2597,8 @@
|
||||
"days90Title": "최근 90일",
|
||||
"daysAllLabel": "전체",
|
||||
"daysAllTitle": "전체 기간",
|
||||
"medicationsEmptyAction": "약 추가하기"
|
||||
"medicationsEmptyAction": "약 추가하기",
|
||||
"rangeLabel": "기간"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
|
||||
+2
-1
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user