From 025c88859c669f85f98780c47c42df8c49b33d3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 16 May 2026 02:33:50 +0200
Subject: [PATCH] fix(a11y): wire the HealthScore delta explainer for screen
readers
The delta-explainer trigger now ships aria-expanded + aria-controls and the surrounding delta text gets a parent-owned aria-describedby thread to the explainer body. The mobile path drops the wrapper around the button so the a11y tree shows one interactive element where it used to show two; the button handles Enter/Space natively. The HealthScoreCard threads a useId pair across the delta line and the explainer so the connection is structural, not visual.
---
.../__tests__/health-score-card.test.tsx | 24 +++++++++++
.../health-score-delta-explainer.test.tsx | 41 +++++++++++++++++++
src/components/insights/health-score-card.tsx | 12 +++++-
.../insights/health-score-delta-explainer.tsx | 40 ++++++++++++------
4 files changed, 103 insertions(+), 14 deletions(-)
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(
+ /
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}