fix(a11y): give the adherence and efficacy charts a keyboard way to their days

Both charts open a day on a click or a tap but carried no data table
and no day link, so a keyboard or screen-reader user could not reach
the days at all. Render the chart's points as a data table with day
links, and have the coverage guard require that way on every
day-linked chart or name where it lives instead.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:57:47 +02:00
1 parent efbaf02335
commit ecf56c6a1f
3 files changed
+85 -1

No files matched your search

@@ -63,6 +63,7 @@ import { formatDateShort } from "@/lib/format";
import { makeFormatters } from "@/lib/format-locale";
import { cn } from "@/lib/utils";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartDataTable } from "./chart-data-table";
import { ChartEmptyState } from "./chart-empty-state";
import { ChartErrorState } from "./chart-error-state";
import { ChartOverlayControls } from "./chart-overlay-controls";
@@ -743,6 +744,23 @@ export function MedicationComplianceChart({
insetLeft={8 + 48}
insetRight={8}
/>
{/* The keyboard and screen-reader way to a day: the plot opens
days on a click or a tap only. */}
{chartDays.active ? (
<ChartDataTable
points={chartData.map((point) => ({
date: point.date,
timestamp: point.timestamp,
rate: point.rate,
}))}
columns={[{ key: "rate", label: t("dashboard.compliance7d") }]}
formatValue={(value) => `${fmt.integer(value)} %`}
formatDate={tzFmt.date}
bucket="day"
metricLabel={displayTitle}
dayLinks
/>
) : null}
</>
)}
</div>
@@ -21,7 +21,10 @@ import { describe, expect, it } from "vitest";
* B. a file that draws its own Recharts chart over a date axis (an
* `<XAxis>` keyed on a date-shaped field, or `scale="time"`) calls
* `useChartDayLinks` and renders its parts (`ChartDayFooter`,
* `OPEN_DAY_LINE`, the tooltip's props and its touch action);
* `OPEN_DAY_LINE`, the tooltip's props and its touch action), and
* offers the keyboard way to the same days: a `<ChartDataTable
* dayLinks>` or a `DayLink` of its own, or an entry on
* `KEYBOARD_ELSEWHERE` naming where that way lives;
* C. a calendar heatmap (anything laid out by `heatmapDays`) calls
* `useHeatmapDay`.
*
@@ -83,6 +86,29 @@ const NOT_HERE: Record<string, string> = {
"The server's own resource use, not a health record.",
};
/**
* Day-linked charts whose keyboard and screen-reader way to a day is not in
* their own file. Path → the file that carries it, and why there.
*/
const KEYBOARD_ELSEWHERE: Record<string, { file: string; why: string }> = {
"src/components/labs/lab-biomarker-chart.tsx": {
file: "src/components/labs/lab-history-list.tsx",
why: "The marker's readings list (its values page) dates every reading with a day link; the chart is the shape, the list the numbers.",
},
"src/components/mental-health/assessment-history-chart.tsx": {
file: "src/components/mental-health/assessment-history.tsx",
why: "The questionnaire history renders the chart and, under it, the list of every result with its day link.",
},
};
/** True when the file itself offers a keyboard way to its days. */
function hasKeyboardDoor(text: string): boolean {
return (
jsxTags(text, "ChartDataTable").some(mountOpensDays) ||
/<DayLink(?:At|Stated)?[\s>]/.test(text)
);
}
function walk(path: string): string[] {
const abs = join(ROOT, path);
if (statSync(abs).isFile()) return [abs];
@@ -201,6 +227,12 @@ function findMissingDoors(): {
if (wired) {
doors.charts += 1;
doors.chartFiles.push(file);
if (!hasKeyboardDoor(text) && !(file in KEYBOARD_ELSEWHERE)) {
missing.push({
file,
why: "a day-linked chart with no keyboard way to its days (a <ChartDataTable dayLinks> or a DayLink)",
});
}
} else if (!listed) {
missing.push({
file,
@@ -246,6 +278,19 @@ describe("every chart drawn in days opens its days, or says why not", () => {
expect(doors.heatmaps).toBeGreaterThanOrEqual(2);
});
it("every KEYBOARD_ELSEWHERE entry points at a file with day links", () => {
for (const [chart, { file, why }] of Object.entries(KEYBOARD_ELSEWHERE)) {
expect(source.get(chart), `${chart} does not exist`).toBeDefined();
expect(hasKeyboardDoor(source.get(chart)!), `${chart} has its own`).toBe(
false,
);
const text = source.get(file);
expect(text, `${file} does not exist`).toBeDefined();
expect(hasKeyboardDoor(text!), `${file} has no day link`).toBe(true);
expect(why.length).toBeGreaterThan(30);
}
});
it("every NOT_HERE entry names an existing chart and gives a reason", () => {
for (const [file, reason] of Object.entries(NOT_HERE)) {
const text = source.get(file);
@@ -32,9 +32,11 @@ import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
ChartDayFooter,
OPEN_DAY_LINE,
dayAnchor,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { dateKeyOfInstant } from "@/components/day/day-url";
@@ -406,6 +408,25 @@ export function EfficacyChart({
insetLeft={40}
insetRight={8}
/>
{/* The keyboard and screen-reader way to a day: the plot opens days on
a click or a tap only. Each row stands at its day's anchor, the
calendar day the plot's own door opens. */}
{chartDays.active ? (
<ChartDataTable
points={displaySeries.map((point, index) => ({
date: seriesDays[index]!,
timestamp: dayAnchor(seriesDays[index]!),
value: point.value,
}))}
columns={[{ key: "value", label: target.label }]}
unit={unitSuffix ?? undefined}
formatValue={(value) => String(value)}
formatDate={fmt.date}
bucket="day"
metricLabel={target.label}
dayLinks
/>
) : null}
</div>
);
}