mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user