fix(ui): route the hand-built segment rows through Segmented

The narrative period, the lab result type, both admin filters, the chart
range tabs, the mood chart's own copy of them, the sleep-stage window and
the re-authentication method each spelled a segmented control by hand,
with their own sizes and fills. They render Segmented now; the range tabs
keep their long names as a hover title, and the admin filters gain an
accessible group name in every language. A test refuses a new button row
whose aria-pressed compares a state to each button's value, and names
the rows that remain with why they are not one choice on one track.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 16:43:59 +02:00
1 parent 160da978e6
commit 7ed953a029
21 files changed
+205 -211

No files matched your search

+6 -6
View File
@@ -137,12 +137,12 @@ async function expectSharedTabs(page: Page, chart: string, pressed: string) {
await expect(tabs).toHaveCount(4);
expect(
await tabs.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-range")),
nodes.map((node) => node.getAttribute("data-value")),
),
).toEqual(RANGES);
await expect(
page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
).toHaveAttribute("data-range", pressed);
).toHaveAttribute("data-value", pressed);
}
test.describe("score histories", () => {
@@ -170,7 +170,7 @@ test.describe("score histories", () => {
expect(asked).toContain(30);
await chart
.locator('[data-slot="chart-range-tab"][data-range="7"]')
.locator('[data-slot="chart-range-tab"][data-value="7"]')
.click();
await expect(chart).toHaveAttribute("data-range", "7");
await expect.poll(() => asked.at(-1)).toBe(7);
@@ -185,7 +185,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-range", "7");
await chart
.locator('[data-slot="chart-range-tab"][data-range="0"]')
.locator('[data-slot="chart-range-tab"][data-value="0"]')
.click();
await expect(chart).toHaveAttribute("data-range", "0");
await expect.poll(() => asked.at(-1)).toBe(3650);
@@ -231,7 +231,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-score", "READINESS");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await chart
.locator('[data-slot="chart-range-tab"][data-range="90"]')
.locator('[data-slot="chart-range-tab"][data-value="90"]')
.click();
await expect(chart).toHaveAttribute("data-range", "90");
await expect.poll(() => asked.at(-1)).toBe(90);
@@ -262,7 +262,7 @@ test.describe("score histories", () => {
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
await page
.locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`)
.locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
await page.reload();
+2
View File
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Noch keine API-Tokens",
"tokensEmptyDescription": "Tokens werden bei der Anmeldung nativer Clients oder über Einstellungen → Account ausgestellt.",
"loginOverviewDescription": "Anmelde- und Auth-Ereignisse aller Konten, filterbar und als CSV exportierbar.",
"loginOverviewFilterLabel": "Ereignisse filtern",
"allAuthEvents": "Alle Auth-Ereignisse",
"failedOnly": "Nur fehlgeschlagene",
"loginEmptyDescription": "Anmeldungen, Abmeldungen und Passkey-Änderungen erscheinen hier.",
@@ -7864,6 +7865,7 @@
"admin": "Admins",
"user": "Nutzer"
},
"filterLabel": "Benutzer filtern",
"forceLogout": "Abmeldung erzwingen",
"forceLogoutConfirmTitle": "{name} abmelden?",
"forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Nutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.",
+2
View File
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "No API tokens yet",
"tokensEmptyDescription": "Tokens are issued when a native client signs in or via Settings → Account.",
"loginOverviewDescription": "Sign-in and auth events across all accounts, filterable and exportable as CSV.",
"loginOverviewFilterLabel": "Filter events",
"allAuthEvents": "All auth events",
"failedOnly": "Failed only",
"loginEmptyDescription": "Sign-ins, sign-outs, and passkey changes will show up here.",
@@ -7864,6 +7865,7 @@
"admin": "Admins",
"user": "Users"
},
"filterLabel": "Filter users",
"forceLogout": "Force logout",
"forceLogoutConfirmTitle": "Force logout {name}?",
"forceLogoutConfirmBody": "All active sessions for this user will be revoked. They will need to sign in again on every device.",
+2
View File
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Aún no hay tokens de API",
"tokensEmptyDescription": "Los tokens se emiten cuando un cliente nativo inicia sesión o desde Ajustes → Cuenta.",
"loginOverviewDescription": "Eventos de inicio de sesión y autenticación de todas las cuentas, filtrables y exportables como CSV.",
"loginOverviewFilterLabel": "Filtrar eventos",
"allAuthEvents": "Todos los eventos de autenticación",
"failedOnly": "Solo fallidos",
"loginEmptyDescription": "Aquí aparecerán los inicios de sesión, los cierres de sesión y los cambios de passkey.",
@@ -7864,6 +7865,7 @@
"admin": "Administradores",
"user": "Usuarios"
},
"filterLabel": "Filtrar usuarios",
"forceLogout": "Forzar cierre de sesión",
"forceLogoutConfirmTitle": "¿Forzar el cierre de sesión de {name}?",
"forceLogoutConfirmBody": "Se revocarán todas las sesiones activas de este usuario. Tendrá que volver a iniciar sesión en todos los dispositivos.",
+2
View File
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Aucun jeton d'API pour l'instant",
"tokensEmptyDescription": "Les jetons sont émis lorsqu'un client natif se connecte ou via Paramètres → Compte.",
"loginOverviewDescription": "Événements de connexion et d’authentification de tous les comptes, filtrables et exportables en CSV.",
"loginOverviewFilterLabel": "Filtrer les événements",
"allAuthEvents": "Tous les événements d'authentification",
"failedOnly": "Échecs uniquement",
"loginEmptyDescription": "Les connexions, déconnexions et modifications de passkey apparaîtront ici.",
@@ -7864,6 +7865,7 @@
"admin": "Administrateurs",
"user": "Utilisateurs"
},
"filterLabel": "Filtrer les utilisateurs",
"forceLogout": "Forcer la déconnexion",
"forceLogoutConfirmTitle": "Forcer la déconnexion de {name} ?",
"forceLogoutConfirmBody": "Toutes les sessions actives de cet utilisateur seront révoquées. Il devra se reconnecter sur chaque appareil.",
+2
View File
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Ancora nessun token API",
"tokensEmptyDescription": "I token vengono emessi quando un client nativo accede o tramite Impostazioni → Account.",
"loginOverviewDescription": "Eventi di accesso e autenticazione di tutti gli account, filtrabili ed esportabili in CSV.",
"loginOverviewFilterLabel": "Filtra eventi",
"allAuthEvents": "Tutti gli eventi di autenticazione",
"failedOnly": "Solo non riusciti",
"loginEmptyDescription": "Qui compariranno accessi, disconnessioni e modifiche delle passkey.",
@@ -7864,6 +7865,7 @@
"admin": "Amministratori",
"user": "Utenti"
},
"filterLabel": "Filtra utenti",
"forceLogout": "Forza la disconnessione",
"forceLogoutConfirmTitle": "Forzare la disconnessione di {name}?",
"forceLogoutConfirmBody": "Tutte le sessioni attive di questo utente verranno revocate. Dovrà accedere di nuovo su ogni dispositivo.",
+2
View File
@@ -7711,6 +7711,7 @@
"tokensEmptyTitle": "아직 API 토큰이 없어요",
"tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.",
"loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.",
"loginOverviewFilterLabel": "이벤트 필터",
"allAuthEvents": "전체 인증 이벤트",
"failedOnly": "실패만",
"loginEmptyDescription": "로그인, 로그아웃, 패스키 변경이 여기에 표시돼요.",
@@ -7839,6 +7840,7 @@
"admin": "관리자",
"user": "사용자"
},
"filterLabel": "사용자 필터",
"forceLogout": "강제 로그아웃",
"forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?",
"forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.",
+2
View File
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Nog geen API-tokens",
"tokensEmptyDescription": "Tokens worden uitgegeven wanneer een native client inlogt of via Instellingen → Account.",
"loginOverviewDescription": "Inlog- en authenticatiegebeurtenissen van alle accounts, te filteren en te exporteren als CSV.",
"loginOverviewFilterLabel": "Gebeurtenissen filteren",
"allAuthEvents": "Alle authenticatiegebeurtenissen",
"failedOnly": "Alleen mislukte",
"loginEmptyDescription": "Inlogs, uitlogs en wijzigingen aan passkeys verschijnen hier.",
@@ -7864,6 +7865,7 @@
"admin": "Beheerders",
"user": "Gebruikers"
},
"filterLabel": "Gebruikers filteren",
"forceLogout": "Geforceerd uitloggen",
"forceLogoutConfirmTitle": "{name} geforceerd uitloggen?",
"forceLogoutConfirmBody": "Alle actieve sessies van deze gebruiker worden ingetrokken. Deze gebruiker moet daarna op elk apparaat opnieuw inloggen.",
+2
View File
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Brak tokenów API",
"tokensEmptyDescription": "Tokeny są wydawane, gdy klient natywny się loguje, lub przez Ustawienia → Konto.",
"loginOverviewDescription": "Zdarzenia logowania i uwierzytelniania wszystkich kont, z filtrowaniem i eksportem CSV.",
"loginOverviewFilterLabel": "Filtruj zdarzenia",
"allAuthEvents": "Wszystkie zdarzenia uwierzytelniania",
"failedOnly": "Tylko nieudane",
"loginEmptyDescription": "Tutaj pojawią się logowania, wylogowania i zmiany kluczy passkey.",
@@ -7864,6 +7865,7 @@
"admin": "Administratorzy",
"user": "Użytkownicy"
},
"filterLabel": "Filtruj użytkowników",
"forceLogout": "Wymuś wylogowanie",
"forceLogoutConfirmTitle": "Wymusić wylogowanie {name}?",
"forceLogoutConfirmBody": "Wszystkie aktywne sesje tego użytkownika zostaną unieważnione. Będzie musiał zalogować się ponownie na każdym urządzeniu.",
+12 -20
View File
@@ -16,6 +16,7 @@ import {
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
@@ -231,26 +232,17 @@ export function LoginOverviewSection() {
<div className="space-y-3">
{/* Quick-filter pills. Failed-only is the most common
admin "is something wrong" question — keep the one-tap shortcut. */}
<div className="flex gap-1">
<Button
variant={filter === "all" ? "default" : "ghost"}
aria-pressed={filter === "all"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.allAuthEvents")}
</Button>
<Button
variant={filter === "failed" ? "default" : "ghost"}
aria-pressed={filter === "failed"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
>
{t("admin.failedOnly")}
</Button>
</div>
<Segmented
mode="toggle"
label={t("admin.loginOverviewFilterLabel")}
slot="admin-login-filter"
value={filter}
onChange={resetPageOnFilterChange(setFilter)}
options={[
{ value: "all", label: t("admin.allAuthEvents") },
{ value: "failed", label: t("admin.failedOnly") },
]}
/>
{/* Detailed filter row */}
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,1fr))]">
@@ -16,6 +16,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -312,20 +313,17 @@ export function UserManagementSection() {
className="flex flex-wrap items-center gap-1.5"
data-slot="admin-users-filter"
>
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
// Neutral pills: the selected filter is a state, not a call to
// action, so it never takes the primary fill.
variant={filter === value ? "secondary" : "outline"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs sm:min-h-9"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
<Segmented
mode="toggle"
label={t("admin.section.users.filterLabel")}
slot="admin-users-filter-choice"
value={filter}
onChange={setFilter}
options={(["all", "admin", "user"] as const).map((value) => ({
value,
label: t(`admin.section.users.filter.${value}`),
}))}
/>
</div>
{filteredUsers ? (
+13 -18
View File
@@ -1,6 +1,6 @@
"use client";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import type { ChartRangePoints } from "@/lib/dashboard-layout";
import { useTranslations } from "@/lib/i18n/context";
@@ -50,22 +50,17 @@ export function ChartRangeTabs({
}) {
const { t } = useTranslations();
return (
<>
{CHART_RANGE_TABS.map((r) => (
<Button
key={r.labelKey}
variant={value === r.days ? "default" : "ghost"}
aria-pressed={value === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => onChange(r.days)}
title={t(r.titleKey)}
data-slot="chart-range-tab"
data-range={r.days}
>
{t(r.labelKey)}
</Button>
))}
</>
<Segmented
mode="toggle"
label={t("charts.rangeLabel")}
optionSlot={() => "chart-range-tab"}
value={String(value)}
onChange={(next) => onChange(Number(next) as ChartRangePoints)}
options={CHART_RANGE_TABS.map((r) => ({
value: String(r.days),
label: t(r.labelKey),
title: t(r.titleKey),
}))}
/>
);
}
+18 -52
View File
@@ -15,7 +15,6 @@ import {
ReferenceLine,
} from "recharts";
import { useEffect, useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
@@ -43,6 +42,7 @@ import {
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { ChartEmptyState } from "./chart-empty-state";
import { ChartRangeTabs } from "./chart-range-tabs";
import { ChartErrorState } from "./chart-error-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
@@ -127,32 +127,8 @@ interface MoodChartProps {
// --- Constants ---
// The range tabs select a CALENDAR-DAY window ending now, matching
// `health-chart.tsx` (see the note there). Until v1.37.29 this chart
// sliced the last N LOGGED DAYS out of the whole history instead — the
// same "7" tab meant seven calendar days on a weight chart and seven
// logged days (possibly weeks apart) on the mood chart one card over.
const TIME_RANGES_KEYS = [
{
labelKey: "charts.days7Label",
days: 7,
titleKey: "charts.days7Title",
},
{
labelKey: "charts.days30Label",
days: 30,
titleKey: "charts.days30Title",
},
{
labelKey: "charts.days90Label",
days: 90,
titleKey: "charts.days90Title",
},
{
labelKey: "charts.daysAllLabel",
days: 0,
titleKey: "charts.daysAllTitle",
},
] as const;
// `health-chart.tsx`, through the same `ChartRangeTabs` every chart drawn in
// days carries.
// MOOD_LABELS built dynamically via t() in the component
@@ -1126,31 +1102,21 @@ export function MoodChart({
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.days ? "default" : "ghost"}
aria-pressed={rangePoints === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
setRangeHydrated(true);
setRangePoints(r.days);
// v1.30.1 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.days,
});
}
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
{t(r.labelKey)}
</Button>
))}
<ChartRangeTabs
value={rangePoints}
onChange={(days) => {
setRangeHydrated(true);
setRangePoints(days);
// v1.30.1 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: days,
});
}
}}
/>
{/* v1.4.18 — overlay-controls dropdown next to the
range tabs. Only painted when the chart is bound to
a persistent chartKey; ad-hoc usages (/insights mood
@@ -183,10 +183,10 @@ describe("<SleepStageStackedBar>", () => {
expect(html).toContain('data-slot="sleep-stage-window-30"');
// The 7d button is default-active.
expect(html).toMatch(
/data-slot="sleep-stage-window-7"[^>]*aria-pressed="true"/,
/aria-pressed="true"[^>]*data-slot="sleep-stage-window-7"/,
);
expect(html).toMatch(
/data-slot="sleep-stage-window-14"[^>]*aria-pressed="false"/,
/aria-pressed="false"[^>]*data-slot="sleep-stage-window-14"/,
);
});
@@ -94,7 +94,7 @@ describe("<ScoreTrendChart>", () => {
const html = render(30, { points: POINTS, band: null });
const tabs = html.match(/<button[^>]*data-slot="chart-range-tab"[^>]*>/g);
expect(tabs).toHaveLength(4);
expect(tabs!.map((tab) => tab.match(/data-range="(\d+)"/)![1])).toEqual([
expect(tabs!.map((tab) => tab.match(/data-value="(\d+)"/)![1])).toEqual([
"7",
"30",
"90",
@@ -102,7 +102,7 @@ describe("<ScoreTrendChart>", () => {
]);
const pressed = tabs!.filter((tab) => tab.includes('aria-pressed="true"'));
expect(pressed).toHaveLength(1);
expect(pressed[0]).toContain('data-range="30"');
expect(pressed[0]).toContain('data-value="30"');
expect(html).toContain('data-slot="score-trend-chart"');
// The range is remembered under the page's own slot.
expect(prefs.lastKey).toBe("scoreHealth");
@@ -115,7 +115,7 @@ describe("<ScoreTrendChart>", () => {
html.match(/<button[^>]*data-slot="chart-range-tab"[^>]*>/g) ?? []
).filter((tab) => tab.includes('aria-pressed="true"'));
expect(pressed).toHaveLength(1);
expect(pressed[0]).toContain('data-range="90"');
expect(pressed[0]).toContain('data-value="90"');
// The seeded 90-day read is the one the chart drew from.
expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3);
});
@@ -12,6 +12,7 @@ import {
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { Segmented } from "@/components/ui/segmented";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/ui/section-heading";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
@@ -175,29 +176,17 @@ export function PeriodNarrativeCard({
reads best. */}
<div className="flex items-start justify-between gap-2">
{/* Calm segmented control, no chart. */}
<div className="flex gap-1">
{(["week", "month"] as const).map((p) => {
const active = period === p;
return (
<button
key={p}
type="button"
aria-pressed={active}
onClick={() => setPeriod(p)}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p === "week"
? t("insights.narrativeWeek")
: t("insights.narrativeMonth")}
</button>
);
})}
</div>
<Segmented
mode="toggle"
label={t("insights.narrativeTitle")}
slot="period-narrative-period"
value={period}
onChange={setPeriod}
options={[
{ value: "week", label: t("insights.narrativeWeek") },
{ value: "month", label: t("insights.narrativeMonth") },
]}
/>
<AskCoachIconButton
question={t(
period === "week"
@@ -14,7 +14,7 @@ import {
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
ChartDayFooter,
@@ -389,31 +389,21 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
})}
</span>
</div>
<div
// v1.4.27 — bump the gap from `gap-1` to
// `gap-1.5` so the three window-toggle buttons (7d /
// 14d / 30d) breathe enough on Pixel 5 that the active
// pill's border doesn't fuse with the inactive neighbour.
className="flex items-center gap-1.5 self-end sm:self-auto"
data-slot="sleep-stage-window-toggle"
>
{WINDOW_DAYS.map((w) => (
<Button
key={w}
type="button"
variant={windowDays === w ? "default" : "ghost"}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setWindowDays(w)}
aria-pressed={windowDays === w}
data-slot={`sleep-stage-window-${w}`}
>
{/* The page's other range controls read "7T / 30T"; this
one printed an English "7d" beside them. */}
{t("charts.daysNLabel", { days: w })}
</Button>
))}
</div>
{/* The page's other range controls read "7T / 30T"; this one
printed an English "7d" beside them, hence `daysNLabel`. */}
<Segmented
mode="toggle"
label={t("charts.rangeLabel")}
slot="sleep-stage-window-toggle"
optionSlot={(w) => `sleep-stage-window-${w}`}
className="self-end sm:self-auto"
value={String(windowDays)}
onChange={(next) => setWindowDays(Number(next) as WindowSize)}
options={WINDOW_DAYS.map((w) => ({
value: String(w),
label: t("charts.daysNLabel", { days: w }),
}))}
/>
</div>
</CardHeader>
<CardContent>
+13 -22
View File
@@ -8,6 +8,7 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -424,28 +425,18 @@ export function LabForm({
<div className="space-y-1.5">
<Label>{t("labs.form.resultType")}</Label>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant={resultType === "numeric" ? "secondary" : "ghost"}
className="min-h-11 flex-1 sm:min-h-9"
onClick={() => setResultType("numeric")}
aria-pressed={resultType === "numeric"}
>
{t("labs.form.numeric")}
</Button>
<Button
type="button"
size="sm"
variant={resultType === "qualitative" ? "secondary" : "ghost"}
className="min-h-11 flex-1 sm:min-h-9"
onClick={() => setResultType("qualitative")}
aria-pressed={resultType === "qualitative"}
>
{t("labs.form.qualitative")}
</Button>
</div>
<Segmented
mode="toggle"
stretch
label={t("labs.form.resultType")}
slot="lab-form-result-type"
value={resultType}
onChange={setResultType}
options={[
{ value: "numeric", label: t("labs.form.numeric") },
{ value: "qualitative", label: t("labs.form.qualitative") },
]}
/>
</div>
{/* One column at every width, as in the measurement form. A second
@@ -13,7 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
@@ -180,28 +180,19 @@ export function ExistingFactorReauthDialog({
</AlertDialogHeader>
{methods.length > 1 && (
<div
className="grid grid-cols-2 gap-2"
role="group"
aria-label={t("settings.passkeyReauth.methodLabel")}
>
{methods.map((m) => (
<Button
key={m}
type="button"
variant={method === m ? "secondary" : "outline"}
aria-pressed={method === m}
disabled={busy}
onClick={() => {
setMethod(m);
setSecret("");
setAssertionError(null);
}}
>
{labels[m]}
</Button>
))}
</div>
<Segmented
mode="toggle"
stretch
label={t("settings.passkeyReauth.methodLabel")}
value={method}
disabled={busy}
onChange={(m) => {
setMethod(m);
setSecret("");
setAssertionError(null);
}}
options={methods.map((m) => ({ value: m, label: labels[m] }))}
/>
)}
{method === "password" && (
@@ -137,3 +137,62 @@ describe("one segmented control", () => {
expect(defining).toEqual(["components/ui/segmented.tsx"]);
});
});
/**
* A row of buttons whose `aria-pressed` compares a state to each button's
* value is a segmented control spelled by hand: six of them sat beside
* `Segmented` (the narrative period, the lab result type, both admin filters,
* the chart range tabs, the mood chart's copy of them), each with its own
* size, fill and gap. New ones are refused; the ones that remain are not one
* choice on one track, and say why.
*/
describe("no hand-built segmented control", () => {
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
const NOT_A_SEGMENT: Record<string, string> = {
"components/settings/security-section/passkey-list-section.tsx":
"one method is disabled while the account has no passkey; Segmented disables the whole track",
"components/medications/units-per-dose-field.tsx":
"a chip row that opens a free-number field for any other count",
"components/body-sites/body-sites-section.tsx":
"wrapping side chips under a body-site picker, not a track",
"components/illness/log-day-sheet.tsx":
"a graded severity scale per symptom, one square per level",
"components/cycle/log-day-sheet.tsx":
"a graded severity scale per symptom, one circle per level",
"components/charts/chart-overlay-controls.tsx":
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
"components/timeline/timeline-view.tsx":
"a single button that opens the range sheet, beside the zoom Segmented",
};
function pressedByValue(source: string): number {
return (source.match(PRESSED_BY_VALUE) ?? []).length;
}
it("finds none outside the reasoned exceptions", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { stripComments, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const found = walkSourceFiles(src, { floor: 2000 })
.filter((p) => /^(components|app)\//.test(p))
.filter((p) => !p.includes("__tests__/"))
.filter((p) => p !== "components/ui/segmented.tsx")
.filter(
(p) =>
pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0,
)
.sort();
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
});
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
expect(
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
).toBe(2);
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
});
});
+9 -2
View File
@@ -37,6 +37,8 @@ export interface SegmentedOption<T extends string> {
icon?: LucideIcon;
/** Rendered after the label (a connected check beside a system name). */
trailing?: ReactNode;
/** A longer name shown on hover ("Last 7 days" for "7d"). */
title?: string;
}
export interface SegmentedProps<T extends string> {
@@ -118,8 +120,13 @@ export function Segmented<T extends string>({
...getRadioProps(index),
})}
{...(iconOnly
? { "aria-label": option.label, title: option.label }
: {})}
? {
"aria-label": option.label,
title: option.title ?? option.label,
}
: option.title
? { title: option.title }
: {})}
disabled={disabled}
data-value={option.value}
data-slot={segmentSlot?.(option.value)}