mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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(
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user