From 9532f14f3bfc04075022760971089c0c7d982f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 29 May 2026 00:36:04 +0200 Subject: [PATCH] fix(meds): notification section title id no longer duplicates the section heading Split the two title ids so the Switch's aria-labelledby resolves to the row label rather than the section heading. axe duplicate-id gone; the announced name now matches the user's mental model. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/notifications-section.test.tsx | 71 +++++++++++++++++++ .../sections/notifications-section.tsx | 16 +++-- 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/components/medications/sections/__tests__/notifications-section.test.tsx diff --git a/src/components/medications/sections/__tests__/notifications-section.test.tsx b/src/components/medications/sections/__tests__/notifications-section.test.tsx new file mode 100644 index 000000000..5bd69e5f1 --- /dev/null +++ b/src/components/medications/sections/__tests__/notifications-section.test.tsx @@ -0,0 +1,71 @@ +/** + * v1.5.5 F-1 H-1 — pin the split title ids on the notifications row. + * + * The earlier single `TITLE_ID` constant landed on both the section + * heading (``) and the inner + * `` carrying the switch's `aria-labelledby`, producing a + * duplicate-id axe failure. The fix splits the two ids: the section + * heading carries `*-heading`, the inner row label carries + * `*-row-label`, and the switch's `aria-labelledby` points at the + * row label. + */ + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { NotificationsSection } from "@/components/medications/sections/notifications-section"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "test-user", username: "tester", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + }, + }, + }); +} + +function render(node: React.ReactNode, client: QueryClient) { + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" — split title ids (F-1 H-1)", () => { + it("renders the section heading and the row label with distinct ids", () => { + const client = makeClient(); + const html = render( + , + client, + ); + + // Section heading + row label sit on different DOM nodes so axe + // duplicate-id passes. The §10 invariant 4 row-as-hit-target also + // needs the row label to be the aria-labelledby target. + expect(html).toContain('id="medication-detail-notifications-heading"'); + expect(html).toContain('id="medication-detail-notifications-row-label"'); + expect(html).not.toContain('id="medication-detail-notifications-title"'); + + // The switch reaches the row label, not the section heading. + expect(html).toContain( + 'aria-labelledby="medication-detail-notifications-row-label"', + ); + }); +}); diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx index 8031b00d8..628088075 100644 --- a/src/components/medications/sections/notifications-section.tsx +++ b/src/components/medications/sections/notifications-section.tsx @@ -57,7 +57,15 @@ interface UserPrefsResponse { } const SWITCH_ID = "medication-detail-notifications-switch"; -const TITLE_ID = "medication-detail-notifications-title"; +// v1.5.5 F-1 H-1 — split the title id so the section heading and +// the inner switch label resolve to distinct DOM nodes. The earlier +// single `TITLE_ID` sat on both ``'s heading +// (`titleId` prop) and the inner `` carrying the switch's +// `aria-labelledby`, surfacing as an axe duplicate-id failure on +// every detail-page render and pointing the announced name at the +// section heading rather than the user's "Send a reminder" target. +const SECTION_TITLE_ID = "medication-detail-notifications-heading"; +const ROW_TITLE_ID = "medication-detail-notifications-row-label"; const HELPER_ID = "medication-detail-notifications-helper"; export function NotificationsSection({ @@ -131,7 +139,7 @@ export function NotificationsSection({ return ( @@ -155,7 +163,7 @@ export function NotificationsSection({ > {t("medications.detail.notifications.switchLabel")} @@ -174,7 +182,7 @@ export function NotificationsSection({ checked={localEnabled} disabled={submitting} onCheckedChange={(checked) => void flip(checked)} - aria-labelledby={TITLE_ID} + aria-labelledby={ROW_TITLE_ID} aria-describedby={HELPER_ID} />