mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(a11y): keyboard-reachable admin log rows, range-bar marker, heatmap cells
Admin app-log rows opened their JSON detail dialog through a click-only <tr> with no keyboard path; the Action cell now wraps a real <button> that carries the same handler plus a descriptive aria-label, leaving the row onClick as a pointer convenience. The target range-bar's current-value marker showed value + band only in a hover-only tooltip on a non-focusable div. It is now focusable (tabIndex, role=img) with an aria-label that reuses the same value / range / delta copy the tooltip already renders, so focus opens the tooltip and a screen reader gets the text alternative either way. Mood and medication-compliance heatmap day cells were pointer-only SVG rects. Each cell is now focusable with its own per-day aria-label (reusing the existing tooltip copy), and Escape dismisses any open tooltip — pinned (touch) or focus-shown. Reference: 2026-07-17 a11y audit, H1 + M1 + M2.
This commit is contained in:
1 parent
247bc4f33c
commit
4c436d7dfc
15 files changed
+243
-27
No files matched your search
@@ -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 `<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.
|
||||
*/
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ComplianceHeatmap
|
||||
dailyCompliance={{
|
||||
"2026-01-05": { expected: 2, taken: 2, skipped: 0 },
|
||||
}}
|
||||
days={14}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ComplianceHeatmap> — keyboard-reachable day cells", () => {
|
||||
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", () => {
|
||||
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="[^"]+"/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 `<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.
|
||||
*/
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodHeatmap
|
||||
cells={{
|
||||
"2026-01-05": { date: "2026-01-05", score: 4.2, samples: 2 },
|
||||
}}
|
||||
days={14}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<MoodHeatmap> — keyboard-reachable day cells", () => {
|
||||
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", () => {
|
||||
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="[^"]+"/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user