{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 (
+
+ );
+}