fix(a11y): give the injection-site picker a visible focus state and accessible names

The SVG site picker painted its focus ring as the stroke of a transparent
hit-target circle — effectively invisible on the muted body map, a WCAG
2.4.7 risk — and conveyed the recommended next site only through the
dashed ring (no screen-reader cue). Track keyboard focus and paint a
contrasting halo on the filled dot, and fold the recommendation into the
recommended site's aria-label so the rotation guidance reaches assistive
tech. Adds injectionSiteRecommendedAriaLabel across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 21:16:56 +02:00
1 parent ad693cd862
commit f29ac6d96e
8 files changed
+76 -5

No files matched your search

+1
View File
@@ -994,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
"siteAbdomenLeft": "Bauch, unten links",
"siteAbdomenRight": "Bauch, unten rechts",
"siteAbdomenUpperLeft": "Bauch, oben links",
+1
View File
@@ -994,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"injectionSiteRecommendedAriaLabel": "{site} — recommended next site",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
+1
View File
@@ -994,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado",
"siteAbdomenLeft": "Abdomen, inferior izquierdo",
"siteAbdomenRight": "Abdomen, inferior derecho",
"siteAbdomenUpperLeft": "Abdomen, superior izquierdo",
+1
View File
@@ -994,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé",
"siteAbdomenLeft": "Abdomen, en bas à gauche",
"siteAbdomenRight": "Abdomen, en bas à droite",
"siteAbdomenUpperLeft": "Abdomen, en haut à gauche",
+1
View File
@@ -994,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato",
"siteAbdomenLeft": "Addome, basso a sinistra",
"siteAbdomenRight": "Addome, basso a destra",
"siteAbdomenUpperLeft": "Addome, alto a sinistra",
+1
View File
@@ -994,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce",
"siteAbdomenLeft": "Brzuch, dolny lewy",
"siteAbdomenRight": "Brzuch, dolny prawy",
"siteAbdomenUpperLeft": "Brzuch, górny lewy",
@@ -90,6 +90,25 @@ describe("<InjectionSitePicker>", () => {
expect(html).toContain("Recommended next:");
});
it("folds the recommendation into the recommended site's aria-label (SR parity)", () => {
// The "recommended next site" cue must not be purely visual (the
// dashed ring). The recommended site's interactive circle carries an
// aria-label that includes the recommendation, while every other
// site keeps its plain site name.
const html = render(
<InjectionSitePicker
value={null}
history={["ABDOMEN_LEFT", "ABDOMEN_UPPER_LEFT"]}
onChange={() => {}}
/>,
);
// Exactly one site is recommended → exactly one composed label.
const composed =
html.match(/aria-label="[^"]*— recommended next site"/g) ?? [];
expect(composed.length).toBe(1);
});
it("fires onChange(site) when the click handler is invoked", () => {
// SSR can't dispatch DOM events; smoke-check the contract by
// invoking the supplied handler with each enum value. The picker
@@ -130,8 +149,12 @@ describe("<InjectionSitePicker>", () => {
);
// The full set of EN labels must appear in the SSR string —
// verifies the t() lookup matches each enum value's i18n key.
expect(html).toContain('aria-label="Abdomen, lower left"');
// verifies the t() lookup matches each enum value's i18n key. With
// empty history the recommender lands on ABDOMEN_LEFT, so its label
// folds in the "recommended next site" cue.
expect(html).toContain(
'aria-label="Abdomen, lower left — recommended next site"',
);
expect(html).toContain('aria-label="Abdomen, lower right"');
expect(html).toContain('aria-label="Abdomen, upper left"');
expect(html).toContain('aria-label="Abdomen, upper right"');
@@ -149,7 +172,9 @@ describe("<InjectionSitePicker>", () => {
"de",
);
expect(html).toContain('aria-label="Bauch, unten links"');
expect(html).toContain(
'aria-label="Bauch, unten links – empfohlene nächste Stelle"',
);
expect(html).toContain('aria-label="Bauch, unten rechts"');
expect(html).toContain('aria-label="Bauch, oben links"');
expect(html).toContain('aria-label="Bauch, oben rechts"');
@@ -1,5 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "@/lib/i18n/context";
import {
INJECTION_SITE_KEYS,
@@ -59,6 +61,21 @@ export function InjectionSitePicker({
const allowedSet = allowed ? new Set(allowed) : null;
const isAllowed = (site: InjectionSiteKey) =>
allowedSet === null || allowedSet.has(site);
// Track which site currently holds keyboard focus so we can paint a
// high-contrast focus halo on the filled dot itself — a transparent
// hit-target stroke alone is effectively invisible on the muted body
// map (WCAG 2.4.7).
const [focusedSite, setFocusedSite] = useState<InjectionSiteKey | null>(null);
// Accessible name: fold the rotation recommendation into the label so a
// screen-reader user gets the "recommended next site" cue that is
// otherwise conveyed only by the dashed ring.
const siteLabel = (site: InjectionSiteKey): string => {
const name = t(describeInjectionSite(site));
return site === recommended
? t("medications.injectionSiteRecommendedAriaLabel", { site: name })
: name;
};
return (
<div
@@ -155,8 +172,22 @@ export function InjectionSitePicker({
// disabled (dimmed, non-interactive) so the user cannot pick a
// site the server would reject.
const disabled = !isAllowed(site);
const isFocused = site === focusedSite;
return (
<g key={site}>
{/* Keyboard focus halo — a contrasting ring on the filled dot
that only shows while this site holds focus. Painted under
the recommendation ring and the dot so both stay legible. */}
{isFocused && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
r="10.5"
fill="none"
className="stroke-ring"
strokeWidth="2.5"
/>
)}
{isRecommended && !isActive && !disabled && (
<circle
cx={coord.x}
@@ -197,8 +228,17 @@ export function InjectionSitePicker({
tabIndex={disabled ? -1 : 0}
aria-pressed={isActive}
aria-disabled={disabled}
aria-label={t(describeInjectionSite(site))}
aria-label={siteLabel(site)}
onClick={disabled ? undefined : () => onChange(site)}
onFocus={disabled ? undefined : () => setFocusedSite(site)}
onBlur={
disabled
? undefined
: () =>
setFocusedSite((current) =>
current === site ? null : current,
)
}
onKeyDown={
disabled
? undefined
@@ -212,7 +252,7 @@ export function InjectionSitePicker({
className={
disabled
? "cursor-not-allowed focus:outline-none"
: "cursor-pointer focus:outline-none focus-visible:stroke-current focus-visible:stroke-2"
: "cursor-pointer focus:outline-none"
}
/>
</g>