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:
Marc-André Bombeck committed 2026-07-18 01:23:37 +02:00
1 parent 247bc4f33c
commit 4c436d7dfc
15 files changed
+243 -27

No files matched your search

+1
View File
@@ -6056,6 +6056,7 @@
"colAction": "Aktion",
"colDuration": "Dauer",
"colTraceId": "Trace",
"viewDetails": "Details anzeigen für {action} um {timestamp}",
"showing": "{count} von bis zu {bufferMax} gepufferten Events angezeigt",
"empty": "Puffer ist leer",
"emptyDescription": "Es passen noch keine Events zu den Filtern. Nach etwas Traffic auf Aktualisieren tippen.",
+1
View File
@@ -6056,6 +6056,7 @@
"colAction": "Action",
"colDuration": "Duration",
"colTraceId": "Trace",
"viewDetails": "View details for {action} at {timestamp}",
"showing": "Showing {count} of up to {bufferMax} buffered events",
"empty": "Buffer is empty",
"emptyDescription": "No events match the current filters yet. Hit refresh after triggering some traffic.",
+1
View File
@@ -6056,6 +6056,7 @@
"colAction": "Acción",
"colDuration": "Duración",
"colTraceId": "Trace",
"viewDetails": "Ver detalles de {action} a las {timestamp}",
"showing": "Mostrando {count} de hasta {bufferMax} eventos en búfer",
"empty": "El búfer está vacío",
"emptyDescription": "Aún no hay eventos que coincidan con los filtros actuales. Pulsa actualizar tras generar algo de tráfico.",
+1
View File
@@ -6056,6 +6056,7 @@
"colAction": "Action",
"colDuration": "Durée",
"colTraceId": "Trace",
"viewDetails": "Voir les détails de {action} à {timestamp}",
"showing": "Affichage de {count} sur jusqu'à {bufferMax} événements en mémoire tampon",
"empty": "Le tampon est vide",
"emptyDescription": "Aucun événement ne correspond encore aux filtres actuels. Cliquez sur Actualiser après avoir généré du trafic.",
+1
View File
@@ -6056,6 +6056,7 @@
"colAction": "Azione",
"colDuration": "Durata",
"colTraceId": "Trace",
"viewDetails": "Visualizza i dettagli di {action} alle {timestamp}",
"showing": "Visualizzati {count} di un massimo di {bufferMax} eventi nel buffer",
"empty": "Il buffer è vuoto",
"emptyDescription": "Nessun evento corrisponde ancora ai filtri attuali. Premi Aggiorna dopo aver generato un po' di traffico.",
+1
View File
@@ -6056,6 +6056,7 @@
"colAction": "Akcja",
"colDuration": "Czas trwania",
"colTraceId": "Trace",
"viewDetails": "Zobacz szczegóły dla {action} o {timestamp}",
"showing": "Wyświetlono {count} z maksymalnie {bufferMax} buforowanych zdarzeń",
"empty": "Bufor jest pusty",
"emptyDescription": "Żadne zdarzenie nie pasuje jeszcze do bieżących filtrów. Kliknij Odśwież po wygenerowaniu ruchu.",
@@ -98,3 +98,16 @@ describe("AppLogPreviewSection — summary lives outside the scroll wrapper", ()
expect(wrapper![0]).not.toContain("app-log-preview-summary");
});
});
describe("AppLogPreviewSection — row detail is keyboard-reachable (2026-07-17 a11y audit H1)", () => {
// The row itself stays a pointer-only convenience (`onClick` on the
// `<tr>`); a real `<button>` in the Action cell gives keyboard / switch
// users a focusable, activatable path to the same detail dialog.
it("renders a real button carrying the action label and a descriptive aria-label", () => {
const html = render();
expect(html).toMatch(
/<button[^>]*type="button"[^>]*>auth\.login\.success</,
);
expect(html).toContain('aria-label="View details for auth.login.success');
});
});
@@ -230,29 +230,46 @@ export function AppLogPreviewSection() {
</tr>
</thead>
<tbody className="divide-border divide-y">
{events.map((event, i) => (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
{event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind)}
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
</tr>
))}
{events.map((event, i) => {
const actionLabel =
event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind);
return (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
// Pointer convenience only — the row itself is not
// keyboard-reachable; the real interactive control is
// the button below (2026-07-17 a11y audit H1).
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
<button
type="button"
onClick={() => setSelected(event)}
className="focus-visible:ring-ring w-full rounded text-left underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
aria-label={t("admin.section.app-logs.viewDetails", {
action: actionLabel,
timestamp: formatDateTime(event.timestamp),
})}
>
{actionLabel}
</button>
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -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="[^"]+"/);
}
});
});
+33 -1
View File
@@ -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));
}}
/>
);
})}
+33 -1
View File
@@ -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));
}}
/>
);
})}
@@ -161,7 +161,13 @@ describe("<MetricTargetSummary>", () => {
// redundant "Target: 60.1–80.9 kg" string above it.
expect(html).toContain('data-slot="tile-header"');
expect(html).toContain("Target");
expect(html).not.toContain("Target: 60.1–80.9 kg");
// 2026-07-17 a11y audit (M1) — the range-bar marker now carries an
// `aria-label` that reuses this same string as a text alternative
// (screen-reader only, not a rendered heading) — strip attribute
// values before asserting no redundant VISIBLE "Target: …" text node
// exists above the bar.
const visibleText = html.replace(/aria-label="[^"]*"/g, "");
expect(visibleText).not.toContain("Target: 60.1–80.9 kg");
// The min / max endpoints surface as the range-bar axis labels.
expect(html).toContain("60.1 kg");
expect(html).toContain("80.9 kg");
@@ -57,4 +57,16 @@ describe("<RangeBar>", () => {
});
expect(html).toContain("var(--destructive)");
});
it("makes the marker focusable with a text alternative for the value + range (2026-07-17 a11y audit M1)", () => {
const html = render({
value: 72,
min: 60,
max: 100,
unit: "bpm",
});
expect(html).toContain('tabindex="0"');
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="Current: 72 bpm');
});
});
+15 -1
View File
@@ -92,6 +92,17 @@ export function RangeBar({
: t("targets.aboveTarget", { delta: delta.toFixed(1), unit })
: t("targets.inTarget");
// 2026-07-17 a11y audit (M1) — the marker's value + range live only in a
// hover-only Radix tooltip on a non-focusable `<div>`. Reusing the same
// three strings the tooltip already shows as one text alternative closes
// 1.1.1 (no separate copy to drift) — `tabIndex` below makes the trigger
// focusable, which is all Radix needs to also open the tooltip on focus.
const markerAriaLabel = [
t("targets.currentValue", { value: String(value), unit }),
t("targets.targetRangeValue", { min: String(min), max: String(max), unit }),
deltaText,
].join(". ");
return (
<div className="space-y-1.5" data-slot="target-range-bar">
<div className="bg-muted/50 relative h-3 w-full overflow-hidden rounded-full">
@@ -129,7 +140,10 @@ export function RangeBar({
<Tooltip>
<TooltipTrigger asChild>
<div
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border-2 shadow-sm"
role="img"
tabIndex={0}
aria-label={markerAriaLabel}
className="focus-visible:ring-ring absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border-2 shadow-sm focus-visible:ring-2 focus-visible:outline-none"
style={{
left: `${position}%`,
backgroundColor: markerColor,