From ecf56c6a1f4217f692dde059fe6621e1d3fdcfd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:33:23 +0200 Subject: [PATCH] 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. --- .../charts/medication-compliance-chart.tsx | 18 +++++++ .../__tests__/day-door-coverage-guard.test.ts | 47 ++++++++++++++++++- .../detail/efficacy/efficacy-chart.tsx | 21 +++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 6464c3d59..0a5e1659b 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -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 ? ( + ({ + 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} )} diff --git a/src/components/day/__tests__/day-door-coverage-guard.test.ts b/src/components/day/__tests__/day-door-coverage-guard.test.ts index 68dd8f2ac..c820c1b3b 100644 --- a/src/components/day/__tests__/day-door-coverage-guard.test.ts +++ b/src/components/day/__tests__/day-door-coverage-guard.test.ts @@ -21,7 +21,10 @@ import { describe, expect, it } from "vitest"; * B. a file that draws its own Recharts chart over a date axis (an * `` 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 `` 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 = { "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 = { + "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) || + /]/.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 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); diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx index 0d0307570..38c04b8d8 100644 --- a/src/components/medications/detail/efficacy/efficacy-chart.tsx +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -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 ? ( + ({ + 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} ); }