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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 00:53:23 +02:00
1 parent 359b103e90
commit 9532f14f3b
2 files changed
+83 -4

No files matched your search

@@ -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 (`<MedicationDetailSection titleId={…}>`) and the inner
* `<span>` 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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<NotificationsSection> — 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(
<NotificationsSection
medicationId="med-1"
notificationsEnabled={true}
/>,
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"',
);
});
});
@@ -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 `<MedicationDetailSection>`'s heading
// (`titleId` prop) and the inner `<span>` 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 (
<MedicationDetailSection
titleId={TITLE_ID}
titleId={SECTION_TITLE_ID}
title={t("medications.detail.notifications.title")}
dataSlot="medication-detail-notifications-section"
>
@@ -155,7 +163,7 @@ export function NotificationsSection({
>
<span className="space-y-1">
<span
id={TITLE_ID}
id={ROW_TITLE_ID}
className="text-foreground block text-sm font-medium"
>
{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}
/>
</label>