fix(charts): give charts and heatmaps a screen-reader text alternative

Wrap the shared HealthChart plus the mood, medication-compliance, and
scatter-correlation charts in role="img" with a concise aria-label that
states the metric, value range, latest reading, and trend direction, so
VoiceOver and TalkBack announce something for every chart cell and the
~50 insight pages behind the shared component.

Add the same role="img" summary to the mood and compliance heatmaps,
route their tooltip dates through the locale- and timezone-aware
formatter instead of a hardcoded German format, and clamp the pinned
tooltip to the viewport so it no longer overflows the right edge on a
narrow phone.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 02:52:53 +02:00
1 parent b424c37ab0
commit 51d67af28f
7 files changed
+276 -24

No files matched your search

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import deMessages from "../../../../messages/de.json";
/**
* Chart accessibility — the rendered chart must expose an image role with
* a non-empty data-summary label so a screen reader announces the series
* instead of skipping a silent SVG. Mirrors the empty-window render test:
* mock auth + react-query, render to static markup, assert the DOM.
*/
function buildData(): unknown[] {
const out: Array<{ date: string; timestamp: number; PULSE: number }> = [];
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
for (let i = 0; i < 10; i++) {
out.push({
date: `2026-01-${String(i + 1).padStart(2, "0")}`,
timestamp: base + i * 86_400_000,
PULSE: 60 + i,
});
}
return out;
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: null,
isLoading: false,
}),
}));
describe("<HealthChart> — accessibility label", () => {
beforeEach(() => {
vi.resetModules();
});
it("wraps the chart in role=img with a non-empty aria-label when data is present", async () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de" initialMessages={deMessages}>
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
expect(html).toContain('role="img"');
// A role=img wrapper with an empty/absent label is worse than none —
// assert the aria-label attribute carries actual text.
const match = html.match(/role="img"[^>]*aria-label="([^"]*)"/);
expect(match?.[1] ?? "").not.toBe("");
vi.doUnmock("@tanstack/react-query");
});
});
+28 -13
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
interface DailyData {
expected: number;
@@ -72,22 +72,17 @@ function getColor(data: DailyData): string {
return "var(--dracula-red)";
}
function formatDateDE(dateStr: string): string {
// v1.4.27 B7 / BL-P4-2 — parse the day-key against UTC so the
// formatted tooltip label matches the dateKey computation below
// (which uses `toISOString().slice(0, 10)`). Without the UTC
// anchor an SSR server in a non-Berlin timezone could format the
// tick a day off the dateKey it sits under.
const d = new Date(dateStr + "T00:00:00Z");
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`;
}
export function ComplianceHeatmap({
dailyCompliance,
days = 90,
stretch = false,
}: ComplianceHeatmapProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
// locale-aware renderer never lands a day off in either direction.
const formatDay = (dateKey: string) =>
fmt.date(new Date(`${dateKey}T12:00:00Z`));
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
@@ -230,6 +225,16 @@ export function ComplianceHeatmap({
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [dailyCompliance, days, t]);
const activeDays = cells.filter((cell) => cell.data.expected > 0).length;
const summaryLabel =
cells.length === 0 || activeDays === 0
? t("charts.a11y.complianceHeatmapEmpty")
: t("charts.a11y.complianceHeatmap", {
start: formatDay(cells[0].dateKey),
end: formatDay(cells[cells.length - 1].dateKey),
days: activeDays,
});
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
// v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the
@@ -268,6 +273,8 @@ export function ComplianceHeatmap({
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid (the capped cells already size
// off `containerWidth`, so a full window still fills the row) rather
@@ -330,7 +337,7 @@ export function ComplianceHeatmap({
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 `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
};
return (
<rect
@@ -376,7 +383,15 @@ export function ComplianceHeatmap({
{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"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
: tooltip.x + 10,
top: tooltip.y - 30,
}}
>
{tooltip.text}
</div>
+50 -1
View File
@@ -1386,6 +1386,51 @@ export function HealthChart({
const formatTooltipValue = (value: number) => fmt.number(value, 1);
// Accessibility — a concise spoken summary of the primary series so a screen
// reader announces the shape of the chart (range, latest value, direction)
// instead of an unlabelled graphic. Mirrors the visible-range stats the
// chart already holds; falls back to the raw min/max when the stat strip
// is suppressed (mini mode).
const primaryType = types[0];
const chartAriaLabel = useMemo(() => {
const metric = getTypeLabel(primaryType, valueMode, t);
const source = chartDataWithCompare ?? chartData;
const values = (source ?? [])
.map((point) => point[primaryType])
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (values.length === 0) {
return t("charts.a11y.noData", { metric });
}
const stats = visibleStatsByType?.[primaryType];
const minVal = stats?.min ?? Math.min(...values);
const maxVal = stats?.max ?? Math.max(...values);
const latestVal = values[values.length - 1];
const firstVal = values[0];
const epsilon = Math.max(Math.abs(maxVal - minVal) * 0.02, 0.01);
const delta = latestVal - firstVal;
const trend =
delta > epsilon
? t("charts.a11y.trendUp")
: delta < -epsilon
? t("charts.a11y.trendDown")
: t("charts.a11y.trendFlat");
return t("charts.a11y.summary", {
metric,
min: fmt.number(minVal, 1),
max: fmt.number(maxVal, 1),
latest: fmt.number(latestVal, 1),
trend,
});
}, [
primaryType,
valueMode,
t,
chartDataWithCompare,
chartData,
visibleStatsByType,
fmt,
]);
const trendInfo = useMemo(() => {
if (!showTrend || !chartData?.length) return [];
@@ -1734,7 +1779,11 @@ export function HealthChart({
</div>
) : null}
<div className="relative z-10 h-full touch-pan-y">
<div
className="relative z-10 h-full touch-pan-y"
role="img"
aria-label={chartAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartDataWithCompare ?? chartData}
@@ -338,6 +338,47 @@ export function MedicationComplianceChart({
const yAxisFormatter = (value: number) => `${fmt.integer(value)} %`;
const animationsEnabled = !prefersReducedMotion();
// Accessibility — a concise spoken summary of the compliance series so a
// screen reader announces the adherence range, the latest day and the
// direction rather than an unlabelled graphic. Reuses the same 7-day trend
// the chip surfaces; falls back to a first-vs-last comparison when the
// window is too short for the trend helper.
const complianceAriaLabel = ((): string => {
const metric = displayTitle;
const rates = chartData
.map((p) => p.rate)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (rates.length === 0) {
return t("charts.a11y.noData", { metric });
}
const pct = (v: number) => `${fmt.integer(v)} %`;
const minVal = Math.min(...rates);
const maxVal = Math.max(...rates);
const latestVal = rates[rates.length - 1];
let trendWord: string;
if (trend && trend.direction !== "stable") {
trendWord =
trend.direction === "up"
? t("charts.a11y.trendUp")
: t("charts.a11y.trendDown");
} else {
const delta = latestVal - rates[0];
trendWord =
delta > 1
? t("charts.a11y.trendUp")
: delta < -1
? t("charts.a11y.trendDown")
: t("charts.a11y.trendFlat");
}
return t("charts.a11y.summary", {
metric,
min: pct(minVal),
max: pct(maxVal),
latest: pct(latestVal),
trend: trendWord,
});
})();
// Empty-state guard: if the user has zero scheduled doses across the
// whole window we render the title + a "no data" hint, mirroring how
// the other charts handle the empty case (HealthChart returns null
@@ -475,7 +516,11 @@ export function MedicationComplianceChart({
/>
)
) : (
<div className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]">
<div
className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]"
role="img"
aria-label={complianceAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
+35
View File
@@ -616,6 +616,39 @@ export function MoodChart({
// below the "Stimmung" heading. Aligning the STRUCTURE — not just
// the padding values — makes the three trend cards share one title
// baseline and one height. Full (non-mini) mode keeps the Card path.
// Accessibility — a concise spoken summary of the visible mood line so a
// screen reader announces the score range, the latest value and the
// direction rather than an unlabelled graphic.
const moodAriaLabel = (() => {
const metric = displayTitle;
const scores = (chartData ?? [])
.map((d) => d.score)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (scores.length === 0) {
return t("charts.a11y.noData", { metric });
}
const fmtScore = (v: number) => String(Math.round(v * 10) / 10);
const minVal = Math.min(...scores);
const maxVal = Math.max(...scores);
const latestVal = scores[scores.length - 1];
const firstVal = scores[0];
const epsilon = 0.1;
const delta = latestVal - firstVal;
const trend =
delta > epsilon
? t("charts.a11y.trendUp")
: delta < -epsilon
? t("charts.a11y.trendDown")
: t("charts.a11y.trendFlat");
return t("charts.a11y.summary", {
metric,
min: fmtScore(minVal),
max: fmtScore(maxVal),
latest: fmtScore(latestVal),
trend,
});
})();
const chartBody = isLoading ? (
// v1.16.0 — height-matched skeleton band instead of the former
// `h-48` spinner box: the loading state occupies the same box the
@@ -680,6 +713,8 @@ export function MoodChart({
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
} touch-pan-y`}
role="img"
aria-label={moodAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
+29 -9
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
@@ -55,17 +55,17 @@ function getColor(score: number | null): string {
return "var(--dracula-green)";
}
function formatDateDE(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00Z");
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`;
}
export function MoodHeatmap({
cells: cellData,
days = 90,
stretch = false,
}: MoodHeatmapProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
// locale-aware renderer never lands a day off in either direction.
const formatDay = (dateKey: string) =>
fmt.date(new Date(`${dateKey}T12:00:00Z`));
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
@@ -185,6 +185,16 @@ export function MoodHeatmap({
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [cellData, days, t]);
const loggedDays = cells.filter((cell) => cell.cell !== null).length;
const summaryLabel =
cells.length === 0 || loggedDays === 0
? t("charts.a11y.moodHeatmapEmpty")
: t("charts.a11y.moodHeatmap", {
start: formatDay(cells[0].dateKey),
end: formatDay(cells[cells.length - 1].dateKey),
days: loggedDays,
});
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
const cellSize =
@@ -214,6 +224,8 @@ export function MoodHeatmap({
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
@@ -254,13 +266,13 @@ export function MoodHeatmap({
{cells.map((cell) => {
const buildText = (): string => {
if (!cell.cell) {
return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
}
const labelKey = moodLabelKeyForScore(
Math.round(cell.cell.score),
);
const moodLabel = labelKey ? t(labelKey) : "";
return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
return (
<rect
@@ -306,7 +318,15 @@ export function MoodHeatmap({
{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"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
: tooltip.x + 10,
top: tooltip.y - 30,
}}
>
{tooltip.text}
</div>
@@ -10,6 +10,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
/**
* Self-contained correlation scatter chart used by /insights.
@@ -82,6 +83,22 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
tooltipFormatter,
height = 250,
}: ScatterCorrelationChartProps<T>) {
const { t } = useTranslations();
// Accessibility — a concise spoken summary so a screen reader announces
// what the scatter relates and how many points it plots rather than an
// unlabelled graphic.
const count = data?.length ?? 0;
const summary =
count === 0
? t("charts.a11y.scatterNoData", {
xLabel: xAxis.name,
yLabel: yAxis.name,
})
: t("charts.a11y.scatterSummary", {
count,
xLabel: xAxis.name,
yLabel: yAxis.name,
});
// v1.4.27 MB7 / CF-37 — drop the fixed pixel height in favour of a
// responsive aspect-ratio on mobile so the chart never compresses
// below readable axes. The `aspect-square` ratio at `<sm` keeps the
@@ -100,6 +117,8 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto"
style={{ height: undefined }}
data-explicit-height={height}
role="img"
aria-label={summary}
>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 20, bottom: 36, left: 12 }}>