From 34cd41a3590b67ce3e1660cf88c847634db05174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 00:50:35 +0200 Subject: [PATCH] fix(a11y): name the catalogue and practitioner combobox triggers A role=combobox button is not named from its content, so the visible placeholder text left both the vaccine-catalogue trigger and the shared practitioner trigger without an accessible name, and the wrapping field label pointed at an id the button did not carry. Name each from its field label. The practitioner trigger is shared, so the visit form gains the same name. Also settle CSS animations before the accessibility scan and poll the reminder reads in the booster flow: the scan otherwise raced the capture sheet's fade and measured a half-opacity blend as a contrast failure, and the flow read the reminder list before the mint and the re-anchor had propagated. Both wait for the settled state rather than relaxing any assertion. --- e2e/vaccinations.spec.ts | 61 +++++++++++++++---- .../encounters/practitioner-combobox.tsx | 5 ++ .../vaccinations/catalog-picker.tsx | 5 ++ 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts index 37b1bddc4..42f8f5ef4 100644 --- a/e2e/vaccinations.spec.ts +++ b/e2e/vaccinations.spec.ts @@ -198,8 +198,21 @@ test.describe("vaccinations", () => { booster: "confirm", }); - const afterMint = await listReminders(request); - const minted = afterMint.find((r) => !before.has(r.id)); + // Confirming the prompt closes the sheet before the mint's write has + // necessarily propagated to the list route, so poll for the new reminder + // rather than reading once against an in-flight mutation. + await expect + .poll( + async () => + (await listReminders(request)).filter((r) => !before.has(r.id)) + .length, + { timeout: 15_000 }, + ) + .toBe(1); + + const minted = (await listReminders(request)).find( + (r) => !before.has(r.id), + ); expect(minted, "the mint wrote exactly one new reminder").toBeDefined(); expect(minted!.origin).toBe("VORSORGE"); expect(minted!.nextDueAt).toBeTruthy(); @@ -219,17 +232,20 @@ test.describe("vaccinations", () => { await page.goto("/vaccinations"); await captureDose(page, { catalogSlug: "tetanus", booster: "decline" }); - const reminderAfter = (await listReminders(request)).find( - (r) => r.id === minted!.id, - ); - expect( - reminderAfter, - "the reminder survives the second dose", - ).toBeDefined(); - expect(reminderAfter!.nextDueAt).toBeTruthy(); - expect(new Date(reminderAfter!.nextDueAt!).getTime()).toBeGreaterThan( - dueBefore, - ); + // The satisfy re-anchor runs inside the second dose's create transaction; + // poll the reminder's due date forward rather than reading once, so a scan + // that beats the commit does not read the pre-anchor value. + await expect + .poll( + async () => { + const row = (await listReminders(request)).find( + (r) => r.id === minted!.id, + ); + return row?.nextDueAt ? new Date(row.nextDueAt).getTime() : null; + }, + { timeout: 15_000 }, + ) + .toBeGreaterThan(dueBefore); }); test("turning the module off drops the nav entry and the direct visit redirects", async ({ @@ -287,8 +303,27 @@ test.describe("vaccinations", () => { }); }); +/** + * Wait until every running CSS animation/transition on the page has settled. + * + * The capture sheet fades in, and axe reads computed colours: a scan that races + * the fade measures a half-opacity blend of the muted foreground over the card + * and reports phantom `color-contrast` failures that clear the instant the + * animation lands. Settling first makes the scan read the real, final surface — + * it never relaxes what axe asserts. + */ +async function waitForAnimationsSettled(page: Page): Promise { + await page.evaluate(async () => { + const anims = document + .getAnimations() + .map((a) => a.finished.catch(() => undefined)); + await Promise.all(anims); + }); +} + /** Fail only on serious/critical WCAG violations, the suite's a11y floor. */ async function expectNoSeriousAxe(page: Page): Promise { + await waitForAnimationsSettled(page); const results = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); diff --git a/src/components/encounters/practitioner-combobox.tsx b/src/components/encounters/practitioner-combobox.tsx index 453f0de31..c28dded64 100644 --- a/src/components/encounters/practitioner-combobox.tsx +++ b/src/components/encounters/practitioner-combobox.tsx @@ -67,6 +67,11 @@ export function PractitionerCombobox({ variant="outline" role="combobox" aria-expanded={open} + // `role="combobox"` is not a name-from-content role: the visible + // placeholder span does not give the trigger an accessible name, + // so name it from the field's own label. Applies wherever this + // combobox is embedded (the visit form and the immunization form). + aria-label={t("encounters.form.practitioner")} disabled={disabled} data-slot="encounter-practitioner-trigger" className="min-h-11 min-w-0 flex-1 justify-between font-normal" diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx index 46243c4c2..8c2507f59 100644 --- a/src/components/vaccinations/catalog-picker.tsx +++ b/src/components/vaccinations/catalog-picker.tsx @@ -77,6 +77,11 @@ export function CatalogPicker({ variant="outline" role="combobox" aria-expanded={open} + // `role="combobox"` is not a name-from-content role, so the + // visible placeholder span does not name the control for a screen + // reader and the wrapping field label targets an id this button + // does not carry. Name it explicitly with the field's own label. + aria-label={t("vaccinations.form.catalogLabel")} disabled={disabled} data-slot="vaccination-catalog-trigger" className="min-h-11 min-w-0 flex-1 justify-between font-normal"