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 <span onClick onKeyDown> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 02:33:50 +02:00
1 parent 0d256230ef
commit 025c88859c
4 files changed
+103 -14

No files matched your search

@@ -127,6 +127,30 @@ describe("<HealthScoreCard>", () => {
expect(html).toContain("-7 vs last week");
});
it("threads aria-describedby from the delta text to the explainer body", () => {
// FB-I1 a11y — the delta `<span>` 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(
<HealthScoreCard
score={64}
band="yellow"
components={baseComponents}
delta={-3}
/>,
);
// The aria-describedby attribute lands on the delta span.
const deltaSpan = html.match(
/<span\s+aria-describedby="([^"]+)"[^>]*>[^<]*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(
<HealthScoreCard
@@ -70,6 +70,47 @@ describe("<HealthScoreDeltaExplainer>", () => {
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(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*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(
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
);
const trigger = html.match(
/<button[^>]*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 <span> (single interactive element)", () => {
// The earlier mobile path wrapped the button in a
// `<span onClick onKeyDown>` 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(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toMatch(
/<span[^>]*onclick/i,
);
});
it("renders the trigger in German with the localised aria-label", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={2} />, "de");
const trigger = html.match(
+10 -2
View File
@@ -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 `<span>` (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<ComponentKey>
@@ -353,12 +358,15 @@ export function HealthScoreCard({
aria-hidden="true"
/>
)}
<span>
<span aria-describedby={deltaExplainerId}>
{t("insights.healthScore.deltaVsLastWeek", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
</span>
<HealthScoreDeltaExplainer delta={delta} />
<HealthScoreDeltaExplainer
delta={delta}
bodyId={deltaExplainerId}
/>
</>
)}
</p>
@@ -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
* `<span>` 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 `<span onClick onKeyDown>`
// 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 = (
<button
type="button"
data-slot="health-score-delta-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={resolvedBodyId}
className={cn(
// Visible glyph stays 12 px so the chip reads at the delta
// line's height without inflating the row. The button itself
@@ -78,6 +101,7 @@ export function HealthScoreDeltaExplainer({
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={handleOpen}
>
<HelpCircle className="h-3 w-3" aria-hidden="true" />
</button>
@@ -86,17 +110,7 @@ export function HealthScoreDeltaExplainer({
if (isMobile) {
return (
<>
<span
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
>
{trigger}
</span>
{trigger}
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
@@ -107,6 +121,7 @@ export function HealthScoreDeltaExplainer({
)}
>
<p
id={bodyId}
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
@@ -133,6 +148,7 @@ export function HealthScoreDeltaExplainer({
{title}
</p>
<p
id={resolvedBodyId}
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-[11px] leading-snug"
>