From 4c436d7dfc073389b0cf49f8f650a2b97f1c1c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:19:05 +0200 Subject: [PATCH] fix(a11y): keyboard-reachable admin log rows, range-bar marker, heatmap cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin app-log rows opened their JSON detail dialog through a click-only with no keyboard path; the Action cell now wraps a real + + + {event.duration_ms} ms + + + {event.trace_id.slice(0, 8)}… + + + ); + })} diff --git a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx new file mode 100644 index 000000000..5e5de3c15 --- /dev/null +++ b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ComplianceHeatmap } from "../compliance-heatmap"; + +/** + * 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells + * were pointer-only SVG ``s: no `tabIndex`, no per-day `aria-label`. + * This pins the keyboard-reachability fix on top of the pre-existing + * aggregate `role="img"` summary on the SVG. + */ +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — keyboard-reachable day cells", () => { + it("keeps the aggregate role=img summary on the SVG", () => { + const html = render(); + expect(html).toMatch(/]*role="img"[^>]*aria-label="[^"]+"/); + }); + + it("makes every day cell focusable with its own aria-label", () => { + const html = render(); + const rectMatches = html.match(/]*>/g) ?? []; + expect(rectMatches.length).toBeGreaterThan(0); + for (const rect of rectMatches) { + expect(rect).toContain('tabindex="0"'); + expect(rect).toContain('role="img"'); + expect(rect).toMatch(/aria-label="[^"]+"/); + } + }); +}); diff --git a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx new file mode 100644 index 000000000..61db1fc72 --- /dev/null +++ b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodHeatmap } from "../mood-heatmap"; + +/** + * 2026-07-17 a11y audit (M2) — mood heatmap day cells were pointer-only + * SVG ``s: no `tabIndex`, no per-day `aria-label`. This pins the + * keyboard-reachability fix (each cell focusable + self-describing) on + * top of the pre-existing aggregate `role="img"` summary on the SVG. + */ +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — keyboard-reachable day cells", () => { + it("keeps the aggregate role=img summary on the SVG", () => { + const html = render(); + expect(html).toMatch(/]*role="img"[^>]*aria-label="[^"]+"/); + }); + + it("makes every day cell focusable with its own aria-label", () => { + const html = render(); + const rectMatches = html.match(/]*>/g) ?? []; + expect(rectMatches.length).toBeGreaterThan(0); + for (const rect of rectMatches) { + expect(rect).toContain('tabindex="0"'); + expect(rect).toContain('role="img"'); + expect(rect).toMatch(/aria-label="[^"]+"/); + } + }); +}); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 06071fb69..b4a506449 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -119,6 +119,20 @@ export function ComplianceHeatmap({ }; }, [tooltip?.pinned]); + // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip + // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's + // "dismissible" requirement. + useEffect(() => { + if (!tooltip) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setTooltip(null); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tooltip]); + const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), "", @@ -348,7 +362,14 @@ export function ComplianceHeatmap({ height={cellSize} rx={2} fill={cell.color} - className="cursor-pointer" + className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]" + // 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on + // the whole SVG summarises the window; each cell also gets + // its own per-day label so a screen-reader / keyboard user + // can reach the same value the pointer-only tooltip shows. + tabIndex={0} + role="img" + aria-label={buildText()} onPointerEnter={(e) => { // Touch enter fires synthetically immediately before // `pointerdown`; skip it so the pinned tooltip below @@ -373,6 +394,17 @@ export function ComplianceHeatmap({ pinned: true, }); }} + onFocus={(e) => { + const box = e.currentTarget.getBoundingClientRect(); + setTooltip({ + x: box.left + box.width / 2, + y: box.top, + text: buildText(), + }); + }} + onBlur={() => { + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} /> ); })} diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 2c4e1ca45..2a06f99c1 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -90,6 +90,20 @@ export function MoodHeatmap({ }; }, [tooltip?.pinned]); + // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip + // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's + // "dismissible" requirement. + useEffect(() => { + if (!tooltip) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setTooltip(null); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tooltip]); + const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), "", @@ -287,7 +301,14 @@ export function MoodHeatmap({ // mood band reads clearly; only the no-entry cells stay the // quiet `--secondary` empty-state tint. fillOpacity={1} - className="cursor-pointer" + className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]" + // 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on + // the whole SVG summarises the window; each cell also gets + // its own per-day label so a screen-reader / keyboard user + // can reach the same value the pointer-only tooltip shows. + tabIndex={0} + role="img" + aria-label={buildText()} onPointerEnter={(e) => { if (e.pointerType === "touch") return; setTooltip({ @@ -309,6 +330,17 @@ export function MoodHeatmap({ pinned: true, }); }} + onFocus={(e) => { + const box = e.currentTarget.getBoundingClientRect(); + setTooltip({ + x: box.left + box.width / 2, + y: box.top, + text: buildText(), + }); + }} + onBlur={() => { + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} /> ); })} diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 18e781bb6..55d3b021b 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -161,7 +161,13 @@ describe("", () => { // redundant "Target: 60.1–80.9 kg" string above it. expect(html).toContain('data-slot="tile-header"'); expect(html).toContain("Target"); - expect(html).not.toContain("Target: 60.1–80.9 kg"); + // 2026-07-17 a11y audit (M1) — the range-bar marker now carries an + // `aria-label` that reuses this same string as a text alternative + // (screen-reader only, not a rendered heading) — strip attribute + // values before asserting no redundant VISIBLE "Target: …" text node + // exists above the bar. + const visibleText = html.replace(/aria-label="[^"]*"/g, ""); + expect(visibleText).not.toContain("Target: 60.1–80.9 kg"); // The min / max endpoints surface as the range-bar axis labels. expect(html).toContain("60.1 kg"); expect(html).toContain("80.9 kg"); diff --git a/src/components/targets/__tests__/range-bar.test.tsx b/src/components/targets/__tests__/range-bar.test.tsx index 5a5dd962a..bb045a3a4 100644 --- a/src/components/targets/__tests__/range-bar.test.tsx +++ b/src/components/targets/__tests__/range-bar.test.tsx @@ -57,4 +57,16 @@ describe("", () => { }); expect(html).toContain("var(--destructive)"); }); + + it("makes the marker focusable with a text alternative for the value + range (2026-07-17 a11y audit M1)", () => { + const html = render({ + value: 72, + min: 60, + max: 100, + unit: "bpm", + }); + expect(html).toContain('tabindex="0"'); + expect(html).toContain('role="img"'); + expect(html).toContain('aria-label="Current: 72 bpm'); + }); }); diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx index 2231adbbb..e1b8a9c78 100644 --- a/src/components/targets/range-bar.tsx +++ b/src/components/targets/range-bar.tsx @@ -92,6 +92,17 @@ export function RangeBar({ : t("targets.aboveTarget", { delta: delta.toFixed(1), unit }) : t("targets.inTarget"); + // 2026-07-17 a11y audit (M1) — the marker's value + range live only in a + // hover-only Radix tooltip on a non-focusable `
`. Reusing the same + // three strings the tooltip already shows as one text alternative closes + // 1.1.1 (no separate copy to drift) — `tabIndex` below makes the trigger + // focusable, which is all Radix needs to also open the tooltip on focus. + const markerAriaLabel = [ + t("targets.currentValue", { value: String(value), unit }), + t("targets.targetRangeValue", { min: String(min), max: String(max), unit }), + deltaText, + ].join(". "); + return (
@@ -129,7 +140,10 @@ export function RangeBar({