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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 20:50:07 +02:00
1 parent 562ff8a277
commit 6d23cca4a3
8 files changed
+183 -96

No files matched your search

+6
View File
@@ -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);
@@ -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,
@@ -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,
+41 -25
View File
@@ -493,33 +493,14 @@ export function LoginOverviewSection() {
</td>
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
{entry.ipAddress ? (
<button
type="button"
onClick={() =>
<IpCopyButton
ip={entry.ipAddress}
copied={copiedIp === entry.id}
label={t("admin.copyIp")}
onCopy={() =>
copyIp(entry.id, entry.ipAddress as string)
}
title={entry.ipAddress}
data-slot="login-overview-ip"
className="hover:text-foreground focus-visible:ring-ring inline-flex max-w-[10rem] items-center gap-1 rounded font-mono focus-visible:ring-2 focus-visible:outline-none"
>
<span className="sr-only">
{t("admin.copyIp")}
</span>
<span className="truncate">
{entry.ipAddress}
</span>
{copiedIp === entry.id ? (
<Check
className="text-success h-3 w-3 shrink-0"
aria-hidden="true"
/>
) : (
<Copy
className="h-3 w-3 shrink-0 opacity-60"
aria-hidden="true"
/>
)}
</button>
/>
) : (
"—"
)}
@@ -591,3 +572,38 @@ export function LoginOverviewSection() {
</SettingsCard>
);
}
/**
* 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 (
<button
type="button"
onClick={onCopy}
title={ip}
data-slot="login-overview-ip"
className="hover:text-foreground focus-visible:ring-ring inline-flex max-w-[10rem] items-center gap-1 rounded font-mono focus-visible:ring-2 focus-visible:outline-none"
>
<span className="sr-only">{label}</span>
<span className="truncate">{ip}</span>
{copied ? (
<Check className="text-success h-3 w-3 shrink-0" aria-hidden="true" />
) : (
<Copy className="h-3 w-3 shrink-0 opacity-60" aria-hidden="true" />
)}
</button>
);
}
+16 -13
View File
@@ -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)
+29 -9
View File
@@ -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. */}
<Link
href="/insights"
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
<ScoreRingLink
purpose={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
>
<span className="sr-only">
{t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
</span>
<ScoreRing
score={digest.score?.value ?? null}
band={digest.score ? (digest.score.band as ScoreBand) : undefined}
@@ -347,7 +344,7 @@ export function TodayHero({
flat
label={t("daily.today.scoreLabel")}
/>
</Link>
</ScoreRingLink>
</div>
);
@@ -567,3 +564,26 @@ export function TodayHero({
</section>
);
}
/**
* 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 (
<Link
href="/insights"
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<span className="sr-only">{purpose}</span>
{children}
</Link>
);
}
+40 -17
View File
@@ -238,22 +238,7 @@ function DayLayer() {
const shortLabel = useLongDayLabel("short");
const { t } = useTranslations();
const titleId = useId();
const titleRef = useRef<HTMLHeadingElement | null>(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<HTMLElement>(null);
const stripRef = useRef<HTMLButtonElement>(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 <h2 ref={ref} {...props} />;
}
/**
* 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<HTMLHeadingElement | null>(null);
const pending = useRef(false);
const requestedFrom = useRef<Element | null>(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.
@@ -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({
/>
)}
<TableHead>
<button
type="button"
<SortHeaderButton
label={t("medications.intakeHistoryColTaken")}
action={t("medications.intakeHistorySortByTaken")}
indicator={sortIndicator("takenAt")}
onClick={() => toggleSort("takenAt")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
>
{t("medications.intakeHistoryColTaken")}
<span className="sr-only">
, {t("medications.intakeHistorySortByTaken")}
</span>
{sortIndicator("takenAt")}
</button>
/>
</TableHead>
<TableHead>
<button
type="button"
<SortHeaderButton
label={t("medications.intakeHistoryColScheduled")}
action={t("medications.intakeHistorySortByScheduled")}
indicator={sortIndicator("scheduledFor")}
onClick={() => toggleSort("scheduledFor")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
>
{t("medications.intakeHistoryColScheduled")}
<span className="sr-only">
, {t("medications.intakeHistorySortByScheduled")}
</span>
{sortIndicator("scheduledFor")}
</button>
/>
</TableHead>
<TableHead>
{t("medications.intakeHistoryColStatus")}
@@ -667,3 +657,32 @@ function IntakeRowActions({
</DropdownMenu>
);
}
/**
* 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 (
<button
type="button"
onClick={onClick}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
>
{label}
<span className="sr-only">, {action}</span>
{indicator}
</button>
);
}