mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
ad693cd862
commit
f29ac6d96e
8 files changed
+76
-5
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user