diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 37a03977c..1b8e911c0 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -504,6 +504,12 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy expect(await chart.boundingBox()).toEqual(before); await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0); expect(page.url()).not.toContain("zoom=range"); + // The popover slides and scales in (`slide-in-from-top-2 zoom-in-95`); + // measured mid-animation it sat up to 8 px higher than where it comes to + // rest. Read its place once the entry animation has finished. + await popover.evaluate((el) => + Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), + ); const anchor = await zoom("range").boundingBox(); const box = await popover.boundingBox(); expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1dc83d219..f2336d624 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 124, - "total": 146512, + "total": 146508, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -17,7 +17,7 @@ "components/charts/health-chart.tsx": 2517, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, - "components/documents/document-detail-sheet.tsx": 1305, + "components/documents/document-detail-sheet.tsx": 1304, "components/documents/documents-view.tsx": 1244, "components/insights/coach-panel/chat-bubble.tsx": 888, "components/insights/coach-panel/coach-conversation.tsx": 1286, @@ -25,7 +25,7 @@ "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1086, - "components/layout/sidebar-nav.tsx": 858, + "components/layout/sidebar-nav.tsx": 855, "components/measurement-reminders/vorsorge-section.tsx": 1536, "components/measurements/measurement-form.tsx": 815, "components/measurements/measurement-list.tsx": 2019, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index fe8ee6eb1..9c388e691 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 411, - "total": 148357, + "total": 148298, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -91,10 +91,10 @@ "components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232, "components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525, "components/admin/key-backup-card.tsx :: KeyBackupCard": 238, - "components/admin/login-overview-section.tsx :: LoginOverviewSection": 522, + "components/admin/login-overview-section.tsx :: LoginOverviewSection": 503, "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305, "components/admin/reminders-section.tsx :: RemindersSection": 309, - "components/admin/user-management-section.tsx :: UserManagementSection": 634, + "components/admin/user-management-section.tsx :: UserManagementSection": 626, "components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217, "components/auth/claim-profile.tsx :: ClaimProfile": 429, "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, @@ -111,19 +111,19 @@ "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214, "components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 302, "components/cycle/bbt-chart.tsx :: BbtChart": 244, - "components/cycle/cycle-calendar.tsx :: CycleCalendar": 304, + "components/cycle/cycle-calendar.tsx :: CycleCalendar": 292, "components/cycle/cycle-settings.tsx :: CycleSettings": 228, "components/cycle/cycle-view.tsx :: CycleView": 408, "components/cycle/log-day-sheet.tsx :: LogDaySheet": 753, - "components/daily/today-hero.tsx :: TodayHero": 492, + "components/daily/today-hero.tsx :: TodayHero": 488, "components/dashboard/dashboard-header.tsx :: DashboardHeader": 215, "components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384, - "components/day/day-layer.tsx :: DayLayer": 374, + "components/day/day-layer.tsx :: DayLayer": 360, "components/day/day-view.tsx :: DayView": 309, "components/documents/document-ai-section.tsx :: DocumentAiSection": 262, "components/documents/document-bulk-bar.tsx :: DocumentBulkBar": 204, "components/documents/document-card.tsx :: DocumentCard": 354, - "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 936, + "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 935, "components/documents/document-facts-review.tsx :: DocumentFactsSection": 252, "components/documents/document-facts-review.tsx :: FactRow": 262, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, @@ -194,7 +194,7 @@ "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405, "components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287, "components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224, - "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 437, + "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 427, "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208, "components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267, "components/medications/medication-card.tsx :: MedicationCard": 413, diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 01cded22c..237f34195 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -493,33 +493,14 @@ export function LoginOverviewSection() { {entry.ipAddress ? ( - + /> ) : ( "—" )} @@ -591,3 +572,38 @@ export function LoginOverviewSection() { ); } + +/** + * One login row's IP address, a button that copies it. Named by the address + * it shows with the action added for screen readers, so the accessible name + * contains the visible text (WCAG 2.5.3). + */ +function IpCopyButton({ + ip, + copied, + label, + onCopy, +}: { + ip: string; + copied: boolean; + label: string; + onCopy: () => void; +}) { + return ( + + ); +} diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index 72b431bf3..aa57342ba 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -104,6 +104,21 @@ function named(t: Translate, labelKey: string, valueKey?: string): string { * other-entries ring stays off. Every field here is one the day log can hold * and the grid has no marker of its own for. */ +/** + * A logged period day's marker. The flow grade is restated in the aria text + * so the shading is never colour-only (the legend covers it; the cell names + * it too). + */ +function periodMarker(day: CalendarDay, t: Translate): string { + const flowKey = + day.flow && day.flow !== "NONE" + ? `cycle.calendar.flow${day.flow.charAt(0)}${day.flow.slice(1).toLowerCase()}` + : null; + return flowKey + ? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})` + : t("cycle.calendar.legendPeriod"); +} + export function otherEntryLabels(day: CalendarDay, t: Translate): string[] { const out: string[] = []; if (day.intermenstrualBleeding) @@ -296,19 +311,7 @@ export function CycleCalendar({ !!info?.isPredictedOvulation && !isConfirmedOvulation; const markers: string[] = []; - if (info?.isPeriodLogged) { - // Restate the flow grade in the aria text so the shading is never - // colour-only (the legend covers it; the cell names it too). - const flowKey = - info.flow && info.flow !== "NONE" - ? `cycle.calendar.flow${info.flow.charAt(0)}${info.flow.slice(1).toLowerCase()}` - : null; - markers.push( - flowKey - ? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})` - : t("cycle.calendar.legendPeriod"), - ); - } + if (info?.isPeriodLogged) markers.push(periodMarker(info, t)); if (info?.isPredictedPeriod) markers.push(t("cycle.calendar.legendPredicted")); if (info?.isFertileWindow) diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 8fbe12224..3fff6da8f 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -43,6 +43,7 @@ * score composition yields entirely. The empty-account degrade and the * freshness note are shared by both modes. */ +import type { ReactNode } from "react"; import Link from "next/link"; import { Moon } from "lucide-react"; @@ -331,15 +332,11 @@ export function TodayHero({ {/* The ring opens the Insights overview — the destination the health-score card owns — so every hero ring is a door, not a poster. */} - - - {t("daily.today.ringLink", { - metric: t("daily.today.scoreLabel"), - })} - - + ); @@ -567,3 +564,26 @@ export function TodayHero({ ); } + +/** + * The hero ring as a door to the Insights overview. Named by what it shows + * (the score and its label) with the purpose added for screen readers, so + * the accessible name contains the visible text (WCAG 2.5.3). + */ +function ScoreRingLink({ + purpose, + children, +}: { + purpose: string; + children: ReactNode; +}) { + return ( + + {purpose} + {children} + + ); +} diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index d5a8148e6..cf6a94b36 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -238,22 +238,7 @@ function DayLayer() { const shortLabel = useLongDayLabel("short"); const { t } = useTranslations(); const titleId = useId(); - const titleRef = useRef(null); - // Focus asked for before the day's content had loaded: the heading takes - // it the moment it mounts. - const titleFocusPending = useRef(false); - const attachTitle = useCallback((el: HTMLHeadingElement | null) => { - titleRef.current = el; - if (el && titleFocusPending.current) { - titleFocusPending.current = false; - el.focus({ preventScroll: true }); - } - }, []); - const focusTitle = useCallback(() => { - const el = titleRef.current; - if (el) el.focus({ preventScroll: true }); - else titleFocusPending.current = true; - }, []); + const { attachTitle, focusTitle, cancelTitleFocus } = useTitleFocus(); const panelRef = useRef(null); const stripRef = useRef(null); // Set by a fold (the strip, its shortcut): focus then lands on the strip, @@ -320,6 +305,7 @@ function DayLayer() { restoring.current = false; return; } + cancelTitleFocus(); if (before !== null && shell === "docked") { // Collapsed for a neighbouring panel: focus stays where it was, and the // day counts as folded by the person, who opened the other panel. @@ -343,7 +329,7 @@ function DayLayer() { } } collapsing.current = false; - }, [date, shell, owner, focusTitle]); + }, [date, shell, owner, focusTitle, cancelTitleFocus]); const onStep = useCallback( (delta: number) => { @@ -611,6 +597,43 @@ function DockedTitle({ return

; } +/** + * Focus for the day's heading: at once when it is there, or the moment it + * mounts when the day's content was still loading. A waiting request is + * honoured only while focus is still where it was when the request was made: + * once the person has moved on (the Coach's input took focus on load, then + * they opened the conversations), a heading that mounts late must not pull + * focus back to a day that is about to fold away. It also ends with the day + * (`cancelTitleFocus`) and never lands inside a panel already inert. + */ +function useTitleFocus() { + const titleRef = useRef(null); + const pending = useRef(false); + const requestedFrom = useRef(null); + const attachTitle = useCallback((el: HTMLHeadingElement | null) => { + titleRef.current = el; + if (el && pending.current) { + pending.current = false; + const active = document.activeElement; + const stayed = active === requestedFrom.current || active === null; + if (stayed && !el.closest("[inert]")) el.focus({ preventScroll: true }); + } + }, []); + const focusTitle = useCallback(() => { + const el = titleRef.current; + if (el) { + el.focus({ preventScroll: true }); + return; + } + pending.current = true; + requestedFrom.current = document.activeElement; + }, []); + const cancelTitleFocus = useCallback(() => { + pending.current = false; + }, []); + return { attachTitle, focusTitle, cancelTitleFocus }; +} + /** * Return focus to whatever opened the day; failing that, to `fallback` (the * collapsed edge's control), or to the page. diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index a3fd99f3a..61e437df4 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -3,7 +3,7 @@ import { DayLinkAt } from "@/components/day/day-link"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useQuery } from "@tanstack/react-query"; -import { useState, useMemo } from "react"; +import { useState, useMemo, type ReactNode } from "react"; import Link from "next/link"; import { Loader2, @@ -313,30 +313,20 @@ export function IntakeHistoryListV2({ /> )} - + /> - + /> {t("medications.intakeHistoryColStatus")} @@ -667,3 +657,32 @@ function IntakeRowActions({ ); } + +/** + * A sortable column header: named by the column it shows, with the sort + * action added for screen readers (WCAG 2.5.3, the accessible name contains + * the visible text). + */ +function SortHeaderButton({ + label, + action, + indicator, + onClick, +}: { + label: string; + action: string; + indicator: ReactNode; + onClick: () => void; +}) { + return ( + + ); +}