mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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="[^"]+"/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user