diff --git a/messages/de.json b/messages/de.json index 18bf94738..9252f62a7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6056,6 +6056,7 @@ "colAction": "Aktion", "colDuration": "Dauer", "colTraceId": "Trace", + "viewDetails": "Details anzeigen für {action} um {timestamp}", "showing": "{count} von bis zu {bufferMax} gepufferten Events angezeigt", "empty": "Puffer ist leer", "emptyDescription": "Es passen noch keine Events zu den Filtern. Nach etwas Traffic auf Aktualisieren tippen.", diff --git a/messages/en.json b/messages/en.json index 0c2dfff82..be96b60ab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6056,6 +6056,7 @@ "colAction": "Action", "colDuration": "Duration", "colTraceId": "Trace", + "viewDetails": "View details for {action} at {timestamp}", "showing": "Showing {count} of up to {bufferMax} buffered events", "empty": "Buffer is empty", "emptyDescription": "No events match the current filters yet. Hit refresh after triggering some traffic.", diff --git a/messages/es.json b/messages/es.json index 0d6573ea1..df1777603 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6056,6 +6056,7 @@ "colAction": "Acción", "colDuration": "Duración", "colTraceId": "Trace", + "viewDetails": "Ver detalles de {action} a las {timestamp}", "showing": "Mostrando {count} de hasta {bufferMax} eventos en búfer", "empty": "El búfer está vacío", "emptyDescription": "Aún no hay eventos que coincidan con los filtros actuales. Pulsa actualizar tras generar algo de tráfico.", diff --git a/messages/fr.json b/messages/fr.json index ae6c28eb8..945051dc9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6056,6 +6056,7 @@ "colAction": "Action", "colDuration": "Durée", "colTraceId": "Trace", + "viewDetails": "Voir les détails de {action} à {timestamp}", "showing": "Affichage de {count} sur jusqu'à {bufferMax} événements en mémoire tampon", "empty": "Le tampon est vide", "emptyDescription": "Aucun événement ne correspond encore aux filtres actuels. Cliquez sur Actualiser après avoir généré du trafic.", diff --git a/messages/it.json b/messages/it.json index 9feb86234..44e478b3b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6056,6 +6056,7 @@ "colAction": "Azione", "colDuration": "Durata", "colTraceId": "Trace", + "viewDetails": "Visualizza i dettagli di {action} alle {timestamp}", "showing": "Visualizzati {count} di un massimo di {bufferMax} eventi nel buffer", "empty": "Il buffer è vuoto", "emptyDescription": "Nessun evento corrisponde ancora ai filtri attuali. Premi Aggiorna dopo aver generato un po' di traffico.", diff --git a/messages/pl.json b/messages/pl.json index 9f51e4099..2a51daa76 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6056,6 +6056,7 @@ "colAction": "Akcja", "colDuration": "Czas trwania", "colTraceId": "Trace", + "viewDetails": "Zobacz szczegóły dla {action} o {timestamp}", "showing": "Wyświetlono {count} z maksymalnie {bufferMax} buforowanych zdarzeń", "empty": "Bufor jest pusty", "emptyDescription": "Żadne zdarzenie nie pasuje jeszcze do bieżących filtrów. Kliknij Odśwież po wygenerowaniu ruchu.", diff --git a/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx index be2894fe0..356dc6068 100644 --- a/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx +++ b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx @@ -98,3 +98,16 @@ describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () expect(wrapper![0]).not.toContain("app-log-preview-summary"); }); }); + +describe("AppLogPreviewSection — row detail is keyboard-reachable (2026-07-17 a11y audit H1)", () => { + // The row itself stays a pointer-only convenience (`onClick` on the + // ``); 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({