fix(a11y): WCAG touch-target floor + ARIA pairing across W4–W9 surfaces

Extends the W8.3 44px touch-target rule and the W8e accordion ARIA
contract to surfaces that shipped without them:

- Insights tab-strip pills: pills are primary navigation; promote to
  `min-h-11` so a thumb tap on "Stimmung" cannot bleed into the
  neighbour two pixels below.
- Injection-site picker: raise the invisible hit-target radius from
  11 to 12 SVG units so the rendered diameter (\~25.6 CSS px)
  clears the WCAG 2.5.8 Level AA 24×24 floor. The abdomen left/right
  pair at \Δx=24 caps the upper bound — larger circles would
  collide.
- Source-priority reorder arrows (per-metric + device-type axes):
  promote four `h-7 w-7` icon buttons to `h-11 w-11`.
- Sub-page heading: replace the bare `focus-visible:outline-none`
  with the same ring vocabulary used by insights pills + the Coach
  drawer so sighted-keyboard users get a visible indicator on
  programmatic focus.
- Source-priority per-metric + device-type accordions: pair
  `aria-expanded` with `aria-controls` + matching `id` on the
  panel, mirroring the W8e Provenance contract.
- Maintainership banner dismiss button: 22×22 hit zone replaced by a
  44×44 inline-flex button that keeps the same icon size.
- Injection-site picker group: `aria-label` was leaking the
  cadence-preset string; introduce
  `medications.injectionSitePickerAriaLabel` and translate it
  across all six locales.
- Body-outline SVG: hardcoded English "Body outline" replaced by
  `medications.injectionSiteBodyOutlineAriaLabel` with native
  translations in DE/FR/IT/ES/PL.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 16:55:10 +02:00
1 parent 04dd972358
commit 72fcdc656f
11 files changed
+54 -19

No files matched your search

+2
View File
@@ -573,6 +573,8 @@
"glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite",
"glp1InventoryLow": "Niedriger Bestand — bald nachbestellen",
"glp1LogSideEffect": "Nebenwirkung loggen",
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"siteAbdomenLeft": "Bauch, unten links",
"siteAbdomenRight": "Bauch, unten rechts",
"siteAbdomenUpperLeft": "Bauch, oben links",
+2
View File
@@ -573,6 +573,8 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
+2
View File
@@ -573,6 +573,8 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
+2
View File
@@ -573,6 +573,8 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
+2
View File
@@ -573,6 +573,8 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
+2
View File
@@ -573,6 +573,8 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
@@ -109,7 +109,9 @@ export function MaintainershipBanner() {
type="button"
onClick={handleDismiss}
aria-label={t("i18n.maintainershipBanner.dismiss")}
className="hover:text-foreground -mr-1 -mt-0.5 shrink-0 rounded-sm p-1 transition-colors"
// 44×44 touch-target floor (WCAG 2.5.5). The icon stays small —
// the surrounding hit area is what the user actually taps.
className="hover:text-foreground focus-visible:ring-ring/50 -mr-2 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
@@ -130,7 +130,10 @@ export function InsightsTabStrip({
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
// 44px touch-target floor (W8.3) — pills are primary
// navigation; the regenerate icon-button to the right
// already meets the same minimum.
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
@@ -151,11 +154,10 @@ export function InsightsTabStrip({
title={regenerateLabel}
data-slot="insights-tab-strip-regenerate"
className={cn(
// 44×44 touch target — Marc directive 2026-05-14. The
// pill row above sits 28px tall; the button breaks the
// band height intentionally so the touch target meets
// the WCAG 2.5.5 minimum without forcing the pills to
// grow.
// 44×44 touch target — same WCAG 2.5.5 floor the pill row
// now honours via `min-h-11`. The button stays circular so
// its visual weight matches the existing top-bar icon
// buttons.
"inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"transition-colors",
+5 -1
View File
@@ -68,7 +68,11 @@ export function SubPageShell({
tabIndex={-1}
className={cn(
"text-xl font-semibold sm:text-2xl",
"focus-visible:outline-none",
// a11y: sighted-keyboard users (e.g. "Skip to content")
// need a visible focus indicator on the programmatic
// `headingRef.focus()` call below. Match the focus ring
// vocabulary used on insights pills + Coach affordances.
"rounded-sm focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
>
{title}
@@ -51,13 +51,13 @@ export function InjectionSitePicker({
<div
className={`flex flex-col items-center gap-3 ${className ?? ""}`}
role="group"
aria-label={t("medications.glp1WeeklyPresetTitle")}
aria-label={t("medications.injectionSitePickerAriaLabel")}
>
<svg
viewBox="0 0 120 300"
className="text-foreground/40 h-[320px] w-auto max-w-full"
role="img"
aria-label="Body outline"
aria-label={t("medications.injectionSiteBodyOutlineAriaLabel")}
>
{/* Head */}
<circle
@@ -162,12 +162,17 @@ export function InjectionSitePicker({
}
strokeWidth="1.5"
/>
{/* Invisible 22px hit-target for touch — meets WCAG 2.5.5
without crowding the visible 12px dot. */}
{/* Invisible 24-unit hit-target for touch. The SVG renders
at ≈1.07× scale (320px box / 300-unit viewBox height),
so r=12 gives ≈25.6 CSS px diameter — clearing the
WCAG 2.5.8 Level AA 24×24 floor. Going larger would
overlap the abdomen-left / abdomen-right pair at
Δx=24 units; the picker stays interactive without
spacing collisions. */}
<circle
cx={coord.x}
cy={coord.y}
r="11"
r="12"
fill="transparent"
role="button"
tabIndex={0}
+16 -6
View File
@@ -336,7 +336,7 @@ export function SourcesSection() {
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
onClick={() => moveSource(metric, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
@@ -347,7 +347,7 @@ export function SourcesSection() {
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
onClick={() => moveSource(metric, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
@@ -375,6 +375,7 @@ export function SourcesSection() {
type="button"
onClick={() => setShowPerMetric((prev) => !prev)}
aria-expanded={showPerMetric}
aria-controls="sources-per-metric-panel"
className="text-foreground hover:text-primary flex items-center gap-2 text-sm font-medium transition-colors"
>
<ChevronDown
@@ -387,7 +388,11 @@ export function SourcesSection() {
})}
</button>
{showPerMetric && (
<div className="bg-background/30 border-border space-y-3 rounded-md border p-3">
<div
id="sources-per-metric-panel"
role="region"
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.perMetricHelp")}
</p>
@@ -434,6 +439,7 @@ export function SourcesSection() {
type="button"
onClick={() => setShowDeviceType((prev) => !prev)}
aria-expanded={showDeviceType}
aria-controls="sources-device-type-panel"
className="text-foreground hover:text-primary flex items-center gap-2 text-sm font-medium transition-colors"
>
<ChevronDown
@@ -446,7 +452,11 @@ export function SourcesSection() {
})}
</button>
{showDeviceType && (
<div className="bg-background/30 border-border space-y-3 rounded-md border p-3">
<div
id="sources-device-type-panel"
role="region"
className="bg-background/30 border-border space-y-3 rounded-md border p-3"
>
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.deviceTypeHelp")}
</p>
@@ -484,7 +494,7 @@ export function SourcesSection() {
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
onClick={() =>
moveDeviceType("__default__", index, -1)
}
@@ -497,7 +507,7 @@ export function SourcesSection() {
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
onClick={() => moveDeviceType("__default__", index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending