fix(a11y): give the heatmaps a screen-reader day list

The mood and medication-compliance heatmaps exposed per-day values only
through pointer tooltips. The cells now carry a visually-hidden day list
beside the grid — one entry per day that holds a value — so a screen
reader reaches the same figures without a tab stop per cell. A cell
subtree nested under the SVG's aggregate role=img is pruned from the
accessibility tree anyway, and a year-long grid would otherwise flood
the keyboard order; the rects stay a pure pointer affordance and the
aggregate summary label is unchanged. Tests pin the list and that the
cells carry no tab stops.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 10:27:55 +02:00
1 parent 156c4574b5
commit bfe23c17b3
4 files changed
+150 -85

No files matched your search

@@ -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 `<rect>`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 `<rect>`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(
<I18nProvider initialLocale="en">
<ComplianceHeatmap
dailyCompliance={{
"2026-01-05": { expected: 2, taken: 2, skipped: 0 },
[D0]: { expected: 2, taken: 2, skipped: 0 },
[D1]: { expected: 2, taken: 1, skipped: 1 },
}}
days={14}
/>
@@ -23,20 +35,33 @@ function render() {
);
}
describe("<ComplianceHeatmap> — keyboard-reachable day cells", () => {
describe("<ComplianceHeatmap> — keyboard-reachable day values", () => {
it("keeps the aggregate role=img summary on the SVG", () => {
const html = render();
expect(html).toMatch(/<svg[^>]*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(/<rect\b[^>]*>/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(
/<ul[^>]*data-slot="compliance-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/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%)");
});
});
@@ -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 `<rect>`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
* `<rect>`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(
<I18nProvider initialLocale="en">
<MoodHeatmap
cells={{
"2026-01-05": { date: "2026-01-05", score: 4.2, samples: 2 },
[D0]: { date: D0, score: 4.2, samples: 2 },
[D1]: { date: D1, score: 2.0, samples: 1 },
}}
days={14}
/>
@@ -23,20 +35,31 @@ function render() {
);
}
describe("<MoodHeatmap> — keyboard-reachable day cells", () => {
describe("<MoodHeatmap> — keyboard-reachable day values", () => {
it("keeps the aggregate role=img summary on the SVG", () => {
const html = render();
expect(html).toMatch(/<svg[^>]*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(/<rect\b[^>]*>/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(
/<ul[^>]*data-slot="mood-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/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");
});
});
+46 -36
View File
@@ -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 (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
{/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on `<sm` so the heatmap
@@ -336,23 +362,7 @@ export function ComplianceHeatmap({
cell) to move/clear. `pointerType === "touch"` discriminates
so a hover dismiss never wipes a pinned tooltip. */}
{cells.map((cell) => {
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 (
<rect
key={cell.dateKey}
@@ -362,14 +372,13 @@ export function ComplianceHeatmap({
height={cellSize}
rx={2}
fill={cell.color}
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()}
className="cursor-pointer"
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the 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 on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
// 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));
}}
/>
);
})}
</svg>
</div>
{/* 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 && (
<ul className="sr-only" data-slot="compliance-heatmap-day-list">
{activeCells.map((cell) => (
<li key={cell.dateKey}>{describeCell(cell)}</li>
))}
</ul>
)}
{/* Tooltip */}
{tooltip && (
<div
+36 -29
View File
@@ -226,6 +226,22 @@ export function MoodHeatmap({
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 => {
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 (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
@@ -278,16 +294,7 @@ export function MoodHeatmap({
)}
{cells.map((cell) => {
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 (
<rect
key={cell.dateKey}
@@ -301,14 +308,13 @@ export function MoodHeatmap({
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
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()}
className="cursor-pointer"
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the 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 on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
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));
}}
/>
);
})}
</svg>
</div>
{/* 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 && (
<ul className="sr-only" data-slot="mood-heatmap-day-list">
{loggedCells.map((cell) => (
<li key={cell.dateKey}>{describeCell(cell)}</li>
))}
</ul>
)}
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"