mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
160da978e6
commit
7ed953a029
21 files changed
+205
-211
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -7711,6 +7711,7 @@
|
||||
"tokensEmptyTitle": "아직 API 토큰이 없어요",
|
||||
"tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.",
|
||||
"loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.",
|
||||
"loginOverviewFilterLabel": "이벤트 필터",
|
||||
"allAuthEvents": "전체 인증 이벤트",
|
||||
"failedOnly": "실패만",
|
||||
"loginEmptyDescription": "로그인, 로그아웃, 패스키 변경이 여기에 표시돼요.",
|
||||
@@ -7839,6 +7840,7 @@
|
||||
"admin": "관리자",
|
||||
"user": "사용자"
|
||||
},
|
||||
"filterLabel": "사용자 필터",
|
||||
"forceLogout": "강제 로그아웃",
|
||||
"forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?",
|
||||
"forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user