diff --git a/messages/de.json b/messages/de.json index 427e7d436..d571f4242 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index f89b859b5..3552e65e5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 4e03cd711..2e5d8d824 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 798d3d114..23e0a8dd8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 28e18988d..e15b10574 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 3b1764904..cb5d3660a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/medications/__tests__/injection-site-picker.test.tsx b/src/components/medications/__tests__/injection-site-picker.test.tsx index 20976ff33..edc2ee342 100644 --- a/src/components/medications/__tests__/injection-site-picker.test.tsx +++ b/src/components/medications/__tests__/injection-site-picker.test.tsx @@ -90,6 +90,25 @@ describe("", () => { 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( + {}} + />, + ); + + // 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("", () => { ); // 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("", () => { "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"'); diff --git a/src/components/medications/injection-site-picker.tsx b/src/components/medications/injection-site-picker.tsx index 7d166184f..ad1210511 100644 --- a/src/components/medications/injection-site-picker.tsx +++ b/src/components/medications/injection-site-picker.tsx @@ -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(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 (
+ {/* 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 && ( + + )} {isRecommended && !isActive && !disabled && ( 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" } />