From 7ed953a02992b83754ce2c7e8cba4453688f7a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 16:43:59 +0200 Subject: [PATCH] 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. --- e2e/score-history.spec.ts | 12 ++-- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/ko.json | 2 + messages/nl.json | 2 + messages/pl.json | 2 + .../admin/login-overview-section.tsx | 32 ++++----- .../admin/user-management-section.tsx | 26 ++++--- src/components/charts/chart-range-tabs.tsx | 31 ++++---- src/components/charts/mood-chart.tsx | 70 +++++-------------- .../sleep-stage-stacked-bar.test.tsx | 4 +- .../__tests__/score-trend-chart.test.tsx | 6 +- .../insights/period-narrative-card.tsx | 35 ++++------ .../insights/sleep-stage-stacked-bar.tsx | 42 +++++------ src/components/labs/lab-form.tsx | 35 ++++------ .../existing-factor-reauth-dialog.tsx | 37 ++++------ .../ui/__tests__/segmented.test.tsx | 59 ++++++++++++++++ src/components/ui/segmented.tsx | 11 ++- 21 files changed, 205 insertions(+), 211 deletions(-) diff --git a/e2e/score-history.spec.ts b/e2e/score-history.spec.ts index b91dd5d01..74cd68ddb 100644 --- a/e2e/score-history.spec.ts +++ b/e2e/score-history.spec.ts @@ -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(); diff --git a/messages/de.json b/messages/de.json index 1c92cceec..030c254cc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 20d1ac6a4..a486f636a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 38c7f6f1c..a154ba9bd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 7f653d505..0807d9df1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 6e3618cb8..52629264f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index d1c43962a..3b245cf71 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7711,6 +7711,7 @@ "tokensEmptyTitle": "아직 API 토큰이 없어요", "tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.", "loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.", + "loginOverviewFilterLabel": "이벤트 필터", "allAuthEvents": "전체 인증 이벤트", "failedOnly": "실패만", "loginEmptyDescription": "로그인, 로그아웃, 패스키 변경이 여기에 표시돼요.", @@ -7839,6 +7840,7 @@ "admin": "관리자", "user": "사용자" }, + "filterLabel": "사용자 필터", "forceLogout": "강제 로그아웃", "forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?", "forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.", diff --git a/messages/nl.json b/messages/nl.json index f6540db5e..6732ea4dd 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 49f041e09..21304ed2c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index a6893ce0a..a20d6578d 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -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() {
{/* Quick-filter pills. Failed-only is the most common admin "is something wrong" question — keep the one-tap shortcut. */} -
- - -
+ {/* Detailed filter row */}
diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 2cc1b5aff..6f8f23a34 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -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) => ( - - ))} + ({ + value, + label: t(`admin.section.users.filter.${value}`), + }))} + />
{filteredUsers ? ( diff --git a/src/components/charts/chart-range-tabs.tsx b/src/components/charts/chart-range-tabs.tsx index 0f3ba18b5..4c8528606 100644 --- a/src/components/charts/chart-range-tabs.tsx +++ b/src/components/charts/chart-range-tabs.tsx @@ -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) => ( - - ))} - + "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), + }))} + /> ); } diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 28e9219ed..04c26438d 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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) => ( - - ))} + { + 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 diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index c38bf6bbd..74207d86a 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -183,10 +183,10 @@ describe("", () => { 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"/, ); }); diff --git a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx index 0cda9f76c..3e655d79b 100644 --- a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx +++ b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx @@ -94,7 +94,7 @@ describe("", () => { const html = render(30, { points: POINTS, band: null }); const tabs = html.match(/]*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("", () => { ]); 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("", () => { html.match(/]*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); }); diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 94249a5a1..8602b8bbc 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -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. */}
{/* Calm segmented control, no chart. */} -
- {(["week", "month"] as const).map((p) => { - const active = period === p; - return ( - - ); - })} -
+
-
- {WINDOW_DAYS.map((w) => ( - - ))} -
+ {/* The page's other range controls read "7T / 30T"; this one + printed an English "7d" beside them, hence `daysNLabel`. */} + `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 }), + }))} + />
diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 839244897..b75b4f90b 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -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({
-
- - -
+
{/* One column at every width, as in the measurement form. A second diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx index 63b7234f1..357e8d4a1 100644 --- a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx +++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx @@ -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({ {methods.length > 1 && ( -
- {methods.map((m) => ( - - ))} -
+ { + setMethod(m); + setSecret(""); + setAssertionError(null); + }} + options={methods.map((m) => ({ value: m, label: labels[m] }))} + /> )} {method === "password" && ( diff --git a/src/components/ui/__tests__/segmented.test.tsx b/src/components/ui/__tests__/segmented.test.tsx index b3fef660b..26aa5b325 100644 --- a/src/components/ui/__tests__/segmented.test.tsx +++ b/src/components/ui/__tests__/segmented.test.tsx @@ -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 = { + "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(` +`), + ).toBe(2); + expect(pressedByValue(``)).toBe(0); + }); +}); diff --git a/src/components/ui/segmented.tsx b/src/components/ui/segmented.tsx index 786ccb026..bd19683e0 100644 --- a/src/components/ui/segmented.tsx +++ b/src/components/ui/segmented.tsx @@ -37,6 +37,8 @@ export interface SegmentedOption { 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 { @@ -118,8 +120,13 @@ export function Segmented({ ...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)}