mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
77ba829fcf
commit
34cd41a359
3 files changed
+58
-13
No files matched your search
+48
-13
@@ -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<void> {
|
||||
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<void> {
|
||||
await waitForAnimationsSettled(page);
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user