From 6d23cca4a3c03297028803e910c4b26f8c7d2ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 20:50:07 +0200 Subject: [PATCH] fix(day): keep focus where the person moved it when the day's heading mounts late Opening the conversations beside a docked day dropped focus to the page in most runs. A day opened on load asks for its heading to take focus; when the heading had not mounted yet the request waited, and it was honoured whenever the heading appeared, even after the person had moved on and opened the conversations. Focus jumped into the folding day and fell to the page as it went inert. A waiting request is now honoured only while focus is still where it was asked from, never inside an inert panel, and it ends with the day. The timeline range check measured the popover during its slide-in and read it up to 8 px too high; it now waits for the entry animation. The label-in-name fixes move their name composition into small helpers (score ring link, IP copy button, sortable header, period marker, the day's title focus), so every function ends at or below its old length and the size baselines only fall. --- e2e/timeline.spec.ts | 6 ++ .../baselines/source-file-lines.json | 6 +- .../baselines/source-function-lines.json | 16 ++--- .../admin/login-overview-section.tsx | 66 ++++++++++++------- src/components/cycle/cycle-calendar.tsx | 29 ++++---- src/components/daily/today-hero.tsx | 38 ++++++++--- src/components/day/day-layer.tsx | 57 +++++++++++----- .../medications/intake-history-list-v2.tsx | 61 +++++++++++------ 8 files changed, 183 insertions(+), 96 deletions(-) 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 ( + + ); +}