fix(a11y): give every wa-dialog an accessible name
Build & publish Arch package / arch-package (push) Successful in 2m0s
CI / check (push) Successful in 2m53s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 4m47s

Eleven dialogs passed a `label` that never reached the accessibility
tree: Web Awesome renders it into an <h2 id="title"> in the same shadow
root as the native <dialog> and never points aria-labelledby at it, so
getByRole('dialog', {name}) matched nothing and a screen reader
announced an unnamed dialog. a11y.md lists all of them under "what is
already correct".

utils/name-dialog.ts sets the IDREF, with aria-label as the fallback for
without-header (first-run-wizard), called from each host's updated().
aria-labelledby rather than aria-label because three call sites compute
their label at render time, and the heading re-renders anyway. It waits
for the dialog's own first update: wa-dialog populates its shadow root
in its own update, so a query at the host's firstUpdated names nothing.

Reaching into another library's open shadow root is deliberate and the
failure is bounded — if the structure moves, the query misses and the
dialog is as unnamed as it was.
This commit is contained in:
2026-08-12 14:52:23 -04:00
parent d681a7223e
commit 287b6445fa
14 changed files with 492 additions and 14 deletions
+31 -10
View File
@@ -377,16 +377,37 @@ demand puts the element and its `showModal()` in the same update.
`autotag-view`'s last document keydown listener died with them; it
existed only because its dialogs could not close themselves.
**None of them has an accessible name**, which is worth knowing before
writing a locator or believing the audit's "already correct" list.
Every call site passes `label`; Web Awesome renders it into an `<h2
id="title">` in the same shadow root as the native `<dialog>` and never
points `aria-labelledby` at it, so `getByRole('dialog', {name})`
matches nothing and a screen reader announces an unnamed dialog. The
host is also `display: contents`, so the element carrying the testid
always reports hidden — what is visible is the `<dialog>` inside it,
and what holds the slotted content is the *host's* shadow root, not the
dialog's subtree.
**None of them had an accessible name, and one helper gives all of them
one.** Every call site passes `label`; Web Awesome renders it into an
`<h2 id="title">` in the same shadow root as the native `<dialog>` and
never points `aria-labelledby` at it — so for eleven dialogs
`getByRole('dialog', {name})` matched nothing and a screen reader
announced an unnamed dialog. `utils/name-dialog.ts` sets that IDREF
(and falls back to `aria-label` under `without-header`, which renders
no heading to point at), called from each host's `updated()`.
Three things about it are load-bearing. It **reaches into another
library's shadow root**, which is open but is not API — acceptable
here only because the failure is bounded: if Web Awesome moves the
structure the query misses, nothing is written, and the dialog is as
unnamed as it was. It uses **`aria-labelledby`, not `aria-label`**,
because three call sites compute their label at render time and an
IDREF to the heading Web Awesome re-renders stays correct with nothing
resyncing it. And it **waits for the dialog's own first update**, not
its host's: `wa-dialog` is a Lit element whose shadow root is populated
in *its* update, so a query at the host's `firstUpdated` finds an empty
root and names nothing — the same lifecycle trap that hid
`wa-dropdown-item`'s role from the menu keyboard model.
Two awkwardnesses remain, and they are about *locating* one rather than
naming it. The host is `display: contents`, so the element carrying the
testid always reports hidden — what is visible is the `<dialog>` inside
it — and what holds the slotted content is the *host's* shadow root,
not the dialog's subtree. A third is worth knowing before checking any
of this: the Playwright **a11y snapshot never prints a dialog's name**,
named or not, so it cannot tell you whether this works. `getByRole`
can, and CDP's `Accessibility.getFullAXTree` gives the browser's own
answer.
**A disclosure is a button, and it says what it controls.**
`config-section`'s header was a bare `<div @click>` with no `tabindex`,