diff --git a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx index 5e5de3c15..28ea8a862 100644 --- a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx +++ b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx @@ -5,17 +5,29 @@ 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. + * 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells are + * pointer-only SVG ``s. The keyboard/screen-reader path to the + * per-day values is a visually-hidden day list beside the grid, not + * focusable rects (a cell subtree nested under the SVG's `role="img"` is + * pruned from the a11y tree, and per-cell tab stops would flood the + * keyboard order). This pins that list plus the aggregate `role="img"` + * summary, and that the rects stay a pure pointer affordance. */ +// The grid window is the last `days` days ending today, so seed relative +// dates (UTC-sliced, matching the component) rather than fixed ones that +// would slide out of the window over time. +const dayKey = (offset: number) => + new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10); +const D0 = dayKey(0); +const D1 = dayKey(1); + function render() { return renderToStaticMarkup( @@ -23,20 +35,33 @@ function render() { ); } -describe(" — keyboard-reachable day cells", () => { +describe(" — keyboard-reachable day values", () => { 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", () => { + it("keeps the day cells a pure pointer affordance (no tab stops)", () => { 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="[^"]+"/); + expect(rect).not.toContain("tabindex"); } }); + + it("exposes each active day through a visually-hidden day list", () => { + const html = render(); + // The sr-only list carries one entry per day that had a scheduled or + // taken dose — here the two seeded days. + const listMatch = html.match( + /]*data-slot="compliance-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/, + ); + expect(listMatch).not.toBeNull(); + const items = listMatch![1].match(/]*>/g) ?? []; + expect(items.length).toBe(2); + // Each entry pairs the day with its taken/expected + rate string. + expect(listMatch![1]).toContain("2/2 (100%)"); + expect(listMatch![1]).toContain("1/2 (50%)"); + }); }); diff --git a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx index 61db1fc72..938254e8c 100644 --- a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx +++ b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx @@ -5,17 +5,29 @@ 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. + * 2026-07-17 a11y audit (M2) — mood heatmap day cells are pointer-only SVG + * ``s. The keyboard/screen-reader path to the per-day values is a + * visually-hidden day list beside the grid, not focusable rects (a cell + * subtree nested under the SVG's `role="img"` is pruned from the a11y + * tree, and per-cell tab stops would flood the keyboard order). This pins + * that list plus the aggregate `role="img"` summary, and that the rects + * stay a pure pointer affordance. */ +// The grid window is the last `days` days ending today, so seed relative +// dates (UTC-sliced, matching the component) rather than fixed ones that +// would slide out of the window over time. +const dayKey = (offset: number) => + new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10); +const D0 = dayKey(0); +const D1 = dayKey(1); + function render() { return renderToStaticMarkup( @@ -23,20 +35,31 @@ function render() { ); } -describe(" — keyboard-reachable day cells", () => { +describe(" — keyboard-reachable day values", () => { 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", () => { + it("keeps the day cells a pure pointer affordance (no tab stops)", () => { 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="[^"]+"/); + expect(rect).not.toContain("tabindex"); } }); + + it("exposes each logged day through a visually-hidden day list", () => { + const html = render(); + const listMatch = html.match( + /]*data-slot="mood-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/, + ); + expect(listMatch).not.toBeNull(); + const items = listMatch![1].match(/]*>/g) ?? []; + // One entry per logged day — the two seeded days, not every cell. + expect(items.length).toBe(2); + expect(listMatch![1]).toContain("4.2"); + expect(listMatch![1]).toContain("2.0"); + }); }); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index b4a506449..7eec7a959 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -269,6 +269,32 @@ export function ComplianceHeatmap({ const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP; const svgHeight = headerHeight + 7 * cellSize + 6 * GAP; + // Per-day text alternative, shared by the pointer tooltip and the + // visually-hidden day list below the grid (M2). One source of truth so + // the sr-only list never drifts from what a pointer user reads. + const describeCell = (cell: (typeof cells)[number]): string => { + const rate = + cell.data.expected > 0 + ? Math.min( + 100, + Math.round((cell.data.taken / cell.data.expected) * 100), + ) + : 0; + const hasTimingData = + cell.data.onTime !== undefined || + cell.data.late !== undefined || + cell.data.veryLate !== undefined; + const timingInfo = hasTimingData + ? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}` + : ""; + return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`; + }; + // Only days that carried a scheduled or taken dose go into the sr-only + // list; empty days are covered by the aggregate summary on the SVG. + const activeCells = cells.filter( + (cell) => cell.data.expected > 0 || cell.data.taken > 0, + ); + return (
{/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on ` { - const buildText = (): string => { - const rate = - cell.data.expected > 0 - ? Math.min( - 100, - Math.round((cell.data.taken / cell.data.expected) * 100), - ) - : 0; - const hasTimingData = - cell.data.onTime !== undefined || - cell.data.late !== undefined || - cell.data.veryLate !== undefined; - const timingInfo = hasTimingData - ? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}` - : ""; - return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`; - }; + const buildText = (): string => describeCell(cell); return ( { // Touch enter fires synthetically immediately before // `pointerdown`; skip it so the pinned tooltip below @@ -394,23 +403,24 @@ 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)); - }} /> ); })}
+ {/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG + carries an aggregate `role="img"` summary; this list is the + keyboard/screen-reader path to the same granular values the pointer + tooltip shows, without adding a tab stop per cell. */} + {activeCells.length > 0 && ( +
    + {activeCells.map((cell) => ( +
  • {describeCell(cell)}
  • + ))} +
+ )} + {/* Tooltip */} {tooltip && (
{ + if (!cell.cell) { + return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; + } + const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score)); + const moodLabel = labelKey ? t(labelKey) : ""; + return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + }; + // Only the logged days go into the sr-only list; the empty-tinted cells + // are already covered by the aggregate summary on the SVG, so a rotor + // user hears the days that carry a value rather than a wall of "no entry". + const loggedCells = cells.filter((cell) => cell.cell !== null); + return (
{ - const buildText = (): string => { - if (!cell.cell) { - return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; - } - const labelKey = moodLabelKeyForScore( - Math.round(cell.cell.score), - ); - const moodLabel = labelKey ? t(labelKey) : ""; - return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; - }; + const buildText = (): string => describeCell(cell); return ( { if (e.pointerType === "touch") return; setTooltip({ @@ -330,23 +336,24 @@ 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)); - }} /> ); })}
+ {/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG + carries an aggregate `role="img"` summary; this list is the + keyboard/screen-reader path to the same granular values the pointer + tooltip shows, without adding a tab stop per cell. */} + {loggedCells.length > 0 && ( +
    + {loggedCells.map((cell) => ( +
  • {describeCell(cell)}
  • + ))} +
+ )} + {tooltip && (