diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index a89e0c868..799f9d185 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -127,6 +127,30 @@ describe("", () => { expect(html).toContain("-7 vs last week"); }); + it("threads aria-describedby from the delta text to the explainer body", () => { + // FB-I1 a11y — the delta `` carries aria-describedby; the + // explainer trigger's aria-controls reads the same id. Both + // attributes share one `useId()` thread so SR can connect + // "−3 vs last week" to the three-sentence read. + const html = ssr( + , + ); + // The aria-describedby attribute lands on the delta span. + const deltaSpan = html.match( + /]*>[^<]*vs last week[^<]*<\/span>/, + ); + expect(deltaSpan).not.toBeNull(); + const threadedId = deltaSpan?.[1] ?? ""; + expect(threadedId).not.toBe(""); + // The same id sits on the trigger button's aria-controls. + expect(html).toContain(`aria-controls="${threadedId}"`); + }); + it("renders four component rows with their values", () => { const html = ssr( ", () => { expect(trigger?.[0]).toContain("min-w-11"); }); + it("threads aria-expanded + aria-controls onto the trigger button", () => { + // SR users get a clear "this opens a disclosure" cue via + // aria-expanded; the aria-controls value is the same id the + // body paints so the SR can jump to the explanation after + // the user activates the trigger. + const html = ssr(); + const trigger = html.match( + /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, + ); + expect(trigger).not.toBeNull(); + expect(trigger?.[0]).toContain("aria-expanded"); + expect(trigger?.[0]).toContain("aria-controls"); + }); + + it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => { + // Parent owns the id and paints aria-describedby on the delta + // span; the explainer re-uses the same id for its + // aria-controls thread. The shared id is the seam SR uses to + // jump from the digit to the explanation. + const html = ssr( + , + ); + const trigger = html.match( + /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, + ); + expect(trigger?.[0]).toContain('aria-controls="parent-id-x"'); + }); + + it("does not wrap the trigger in a clickable (single interactive element)", () => { + // The earlier mobile path wrapped the button in a + // `` which created two interactive + // elements in the a11y tree and intercepted clicks on the 2 px + // gap around the button. The button now owns the open toggle + // directly. Pinning the negative so a future refactor can't + // reintroduce the wrapper. + const html = ssr(); + expect(html).not.toMatch( + /]*onclick/i, + ); + }); + it("renders the trigger in German with the localised aria-label", () => { const html = ssr(, "de"); const trigger = html.match( diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 4ce8c21ef..3e6b1c0d3 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -184,6 +184,11 @@ export function HealthScoreCard({ // the card is mounted twice on the same page (lg+ hero strip vs // smaller stacked previews in tests). const panelId = useId(); + // FB-I1 a11y — id wired from the delta `` (aria-describedby) + // to the explainer popover/sheet body. The thread lets screen + // readers connect "−3 vs last week" to the three-sentence read + // without relying on visual proximity alone. + const deltaExplainerId = useId(); const componentEntries = ( Object.keys(components) as Array @@ -353,12 +358,15 @@ export function HealthScoreCard({ aria-hidden="true" /> )} - + {t("insights.healthScore.deltaVsLastWeek", { delta: delta > 0 ? `+${delta}` : `${delta}`, })} - + )}

diff --git a/src/components/insights/health-score-delta-explainer.tsx b/src/components/insights/health-score-delta-explainer.tsx index 96ed55f49..388747662 100644 --- a/src/components/insights/health-score-delta-explainer.tsx +++ b/src/components/insights/health-score-delta-explainer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; import { HelpCircle } from "lucide-react"; import { Popover, @@ -45,25 +45,48 @@ interface HealthScoreDeltaExplainerProps { * align the glyph to the delta line's baseline. */ className?: string; + /** + * Optional id the parent owns and threads to the explainer body via + * the popover/sheet markup. The same id sits on the parent's delta + * `` as `aria-describedby` so screen readers can connect "−3 + * vs last week" to the three-sentence read on demand. + */ + bodyId?: string; } export function HealthScoreDeltaExplainer({ delta, className, + bodyId, }: HealthScoreDeltaExplainerProps) { const { t } = useTranslations(); const isMobile = useIsMobile(); const [open, setOpen] = useState(false); + // Stable fallback id when the parent doesn't supply one. The body + // still paints the id so future consumers can thread the same + // describedby pattern without modifying the explainer. + const generatedId = useId(); + const resolvedBodyId = bodyId ?? generatedId; const triggerLabel = t("insights.healthScore.deltaExplainer.trigger"); const title = t("insights.healthScore.deltaExplainer.title"); const body = t("insights.healthScore.deltaExplainer.body"); + // Direct click handler on the button on both branches. Earlier + // mobile path wrapped the button in a `` + // which created two interactive elements in the a11y tree and + // intercepted clicks on the 2 px gap around the button. The button + // already handles Enter/Space natively; the only thing the wrapper + // owned was the open toggle, which moves onto the button itself. + const handleOpen = () => setOpen(true); + const trigger = ( @@ -86,17 +110,7 @@ export function HealthScoreDeltaExplainer({ if (isMobile) { return ( <> - setOpen(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setOpen(true); - } - }} - > - {trigger} - + {trigger}

@@ -133,6 +148,7 @@ export function HealthScoreDeltaExplainer({ {title}