Files
yellowjacket/e2e/specs/dialog-names.spec.ts
T
logan 287b6445fa
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
fix(a11y): give every wa-dialog an accessible name
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.
2026-08-12 14:52:23 -04:00

91 lines
3.3 KiB
TypeScript

import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* Plan 007 phase 5: every `wa-dialog` in this app was an unnamed dialog.
*
* `a11y.md` lists all of them under "what is already correct" and notes
* that every call site passes a `label`. Both true, and the label 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.
*
* This spec is the reason the fix is believable, and it lives here
* rather than in the component tier for one reason: **only Playwright
* computes an accessible name.** The Vitest tier queries shadow roots
* directly, so it can assert the IDREF is wired and resolves to a
* heading carrying the label — it cannot assert that anything would
* announce it. `getByRole('dialog', { name })` can, and before the fix
* it matched nothing anywhere in this app.
*
* Nor can the a11y *snapshot*: `playwright-cli snapshot` renders this
* dialog as a bare `- dialog [ref=…]` whether it is named by
* `aria-labelledby`, named by `aria-label`, or not named at all —
* checked all three ways against the running app. Verifying the fix by
* reading a snapshot would have reported failure on a working build,
* which is this plan's most-repeated trap wearing an accessibility hat:
* a probe that cannot move is not evidence.
*/
test.describe('a dialog says what it is', () => {
test('the shortcuts overlay is announced by its title', async ({ app }) => {
await app.keyboard.press('?');
await expect(
app.locator('shortcuts-overlay').getByRole('dialog', {
name: 'Keyboard Shortcuts',
}),
).toBeVisible();
await app.keyboard.press('Escape');
await expect(
app.locator('shortcuts-overlay').getByRole('dialog'),
).toHaveCount(0);
});
test('a dialog with a computed label is announced by it', async ({ app }) => {
// `track-details` builds its label at render time ("Track Details"
// or "Batch Edit"), which is why it is the one checked here: the
// fix is an IDREF to the heading Web Awesome re-renders, so a label
// that changes stays announced without anything resyncing it.
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await openRowMenu(app);
await app.getByRole('menuitem', { name: 'Track Details' }).click();
await expect(
app.getByRole('dialog', { name: 'Track Details' }),
).toBeVisible();
// Leave the app as this suite found it — the specs share one
// backend process in file order.
await app.keyboard.press('Escape');
await expect(
app.getByRole('dialog', { name: 'Track Details' }),
).toHaveCount(0);
});
});
/** Right-click the third track row, which is where the menu is anchored. */
async function openRowMenu(app: Page): Promise<void> {
await app.evaluate(() => {
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[role="row"]')[2];
row?.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
clientX: 200,
clientY: 300,
}),
);
});
await expect(app.getByRole('menu', { name: 'Track actions' })).toBeVisible();
}