From 287b6445facf40a2ef6dc8f59d712ad4beed95db Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 14:52:23 -0400 Subject: [PATCH] fix(a11y): give every wa-dialog an accessible name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven dialogs passed a `label` that never reached the accessibility tree: Web Awesome renders it into an

in the same shadow root as the native 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. --- .pi/skills/yellowjacket-dev/SKILL.md | 18 ++- CLAUDE.md | 41 +++-- e2e/specs/dialog-names.spec.ts | 90 +++++++++++ .../components/autotag-view/autotag-view.ts | 10 ++ .../confirm-dialog/confirm-dialog.ts | 10 ++ .../download-picker/download-picker.ts | 6 + .../duplicate-tracks-dialog.ts | 10 ++ .../first-run-wizard/first-run-wizard.ts | 10 ++ .../notifications/notification-host.ts | 10 ++ .../phantom-resolver/phantom-resolver.ts | 10 ++ .../shortcuts-overlay/shortcuts-overlay.ts | 10 ++ .../components/track-details/track-details.ts | 10 ++ frontend/src/utils/name-dialog.ts | 121 ++++++++++++++ frontend/test/components/dialog-names.test.ts | 150 ++++++++++++++++++ 14 files changed, 492 insertions(+), 14 deletions(-) create mode 100644 e2e/specs/dialog-names.spec.ts create mode 100644 frontend/src/utils/name-dialog.ts create mode 100644 frontend/test/components/dialog-names.test.ts diff --git a/.pi/skills/yellowjacket-dev/SKILL.md b/.pi/skills/yellowjacket-dev/SKILL.md index 761cde5..4411d21 100644 --- a/.pi/skills/yellowjacket-dev/SKILL.md +++ b/.pi/skills/yellowjacket-dev/SKILL.md @@ -18,7 +18,7 @@ here has disappeared. ## Read this part before you fail -Nine things cost a cycle each the first time. They are here, not in a +Fourteen things cost a cycle each the first time. They are here, not in a reference, because you need them *before* the failure, not after. - **Time out every binding call.** A bound Go method called with wrong @@ -49,9 +49,19 @@ reference, because you need them *before* the failure, not after. reports hidden; the visible thing is the native `` in its shadow root. The slotted content is in the *host's* shadow root, not in that dialog's subtree, so `toContainText` on the dialog sees only - its chrome. And the dialog has **no accessible name** — Web Awesome - never wires `label` to `aria-labelledby` — so - `getByRole('dialog', {name})` matches nothing. + its chrome. And it has an accessible name **only because + `utils/name-dialog.ts` gives it one** — Web Awesome does not wire + `label` to `aria-labelledby` — so a new dialog that forgets to call + the helper from `updated()` is invisible to + `getByRole('dialog', {name})`. +- **The a11y snapshot cannot check an accessible name on a dialog.** + `playwright-cli snapshot` prints `- dialog [ref=…]` with no name + whether the dialog is named by `aria-labelledby`, by `aria-label`, + or not at all — checked all three ways against the running app. Use + `getByRole('dialog', {name})` in a spec, or CDP + (`Accessibility.getFullAXTree`) for the browser's own computation, + which also reports *where* the name came from. A snapshot read as a + probe here reports failure on a working build. - **Playwright's WebKit does not run on Arch** (Ubuntu-only libs). `--browser=webkit` is CI-only; local work is Chromium. CI runs it with `if: !cancelled()` so a chromium failure does not silently diff --git a/CLAUDE.md b/CLAUDE.md index 3684daf..664eccf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `

` in the same shadow root as the native `` 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 `` 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 +`

` in the same shadow root as the native `` 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 `` 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 `
` with no `tabindex`, diff --git a/e2e/specs/dialog-names.spec.ts b/e2e/specs/dialog-names.spec.ts new file mode 100644 index 0000000..991e827 --- /dev/null +++ b/e2e/specs/dialog-names.spec.ts @@ -0,0 +1,90 @@ +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 + * `

` in the same shadow root as the native `` 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 { + 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(); +} diff --git a/frontend/src/components/autotag-view/autotag-view.ts b/frontend/src/components/autotag-view/autotag-view.ts index f7e172d..b432adb 100644 --- a/frontend/src/components/autotag-view/autotag-view.ts +++ b/frontend/src/components/autotag-view/autotag-view.ts @@ -25,6 +25,7 @@ import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-di import { libraryStore } from '../../store/library-store'; import { notificationStore } from '../../store/notification-store'; import { describeError, explainError } from '../../utils/describe-error'; +import { nameDialogsIn } from '../../utils/name-dialog'; import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; import { confirmAction } from '../confirm-dialog/confirm-dialog'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; @@ -3042,6 +3043,15 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { return html`${this.renderPasteDialog()}${this.renderSearchDialog()}`; } + /** + * Web Awesome renders `label` into a heading it never points the + * `` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { // The layout chrome always renders immediately; each pane owns // its own skeleton while its data resolves, so the user never diff --git a/frontend/src/components/confirm-dialog/confirm-dialog.ts b/frontend/src/components/confirm-dialog/confirm-dialog.ts index 4689cf8..220f5c8 100644 --- a/frontend/src/components/confirm-dialog/confirm-dialog.ts +++ b/frontend/src/components/confirm-dialog/confirm-dialog.ts @@ -17,6 +17,7 @@ import { customElement, query, state } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import { designTokens } from '../../styles/tokens.css'; +import { nameDialogsIn } from '../../utils/name-dialog'; export interface ConfirmRequest { title: string; @@ -103,6 +104,15 @@ export class ConfirmDialog extends LitElement { settle?.(ok); } + /** + * Web Awesome renders `label` into a heading it never points the + * `` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { const request = this.request; diff --git a/frontend/src/components/download-picker/download-picker.ts b/frontend/src/components/download-picker/download-picker.ts index 3888985..26de54c 100644 --- a/frontend/src/components/download-picker/download-picker.ts +++ b/frontend/src/components/download-picker/download-picker.ts @@ -10,6 +10,7 @@ import { downloadStore } from '@store/download-store'; import type { download } from '@go/models'; import './candidate-row'; import { explainError } from '@utils/describe-error'; +import { nameDialogsIn } from '@utils/name-dialog'; /** * The "find this album" dialog: searches every enabled download client, @@ -119,6 +120,11 @@ export class DownloadPicker extends LitElement { ]; override updated(changed: Map) { + // Web Awesome renders `label` into a heading it never points + // the `` at, so the dialog has no accessible name until + // something sets one. See `utils/name-dialog.ts`. + nameDialogsIn(this.shadowRoot); + if (changed.has('open') && this.open) { void this.search(); } diff --git a/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts b/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts index a95dae1..d2290e0 100644 --- a/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts +++ b/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts @@ -5,6 +5,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/switch/switch.js'; import { AddTracksToPlaylist } from '@go/playlist/Service'; import { formatMilliseconds } from '@utils/time'; +import { nameDialogsIn } from '@utils/name-dialog'; interface DuplicateTrack { FilePath: string; @@ -271,6 +272,15 @@ export class DuplicateTracksDialog extends LitElement { // RENDER // ================================================================= + /** + * Web Awesome renders `label` into a heading it never points the + * `` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { const current = this.duplicates[this.currentIndex]; diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts index 41262f7..98a42dd 100644 --- a/frontend/src/components/first-run-wizard/first-run-wizard.ts +++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts @@ -8,6 +8,7 @@ import { } from '@go/library/Library'; import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; import { describeError, explainError } from '@utils/describe-error'; +import { nameDialogsIn } from '@utils/name-dialog'; /** * First-run setup wizard. @@ -164,6 +165,15 @@ export class FirstRunWizard extends LitElement { } `; + /** + * Web Awesome renders `label` into a heading it never points the + * `` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { if (!this.active) return nothing; diff --git a/frontend/src/components/notifications/notification-host.ts b/frontend/src/components/notifications/notification-host.ts index 3f02a44..bb420c2 100644 --- a/frontend/src/components/notifications/notification-host.ts +++ b/frontend/src/components/notifications/notification-host.ts @@ -14,6 +14,7 @@ import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import { notificationStore } from '@store/notification-store'; import { designTokens } from '../../styles/tokens.css'; +import { nameDialogsIn } from '../../utils/name-dialog'; import { noticeStyles, renderNotice } from './notice'; @@ -151,6 +152,15 @@ export class NotificationHost extends LitElement { `; } + /** + * Web Awesome renders `label` into a heading it never points the + * `` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { const stacked = [ ...notificationStore.byLevel('persistent'), diff --git a/frontend/src/components/phantom-resolver/phantom-resolver.ts b/frontend/src/components/phantom-resolver/phantom-resolver.ts index d621d79..0d6adf5 100644 --- a/frontend/src/components/phantom-resolver/phantom-resolver.ts +++ b/frontend/src/components/phantom-resolver/phantom-resolver.ts @@ -16,6 +16,7 @@ import { } from '@go/playlist/Service'; import type { playlist } from '@go/models'; import { formatMilliseconds } from '@utils/time'; +import { nameDialogsIn } from '@utils/name-dialog'; const SEARCH_DEBOUNCE_MS = 400; @@ -922,6 +923,15 @@ export class PhantomResolver extends LitElement { `, ]; + /** + * Web Awesome renders `label` into a heading it never points the + * `` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { return html` ` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { if (!this.isOpen) return nothing; diff --git a/frontend/src/components/track-details/track-details.ts b/frontend/src/components/track-details/track-details.ts index 47cefe4..54d3d59 100644 --- a/frontend/src/components/track-details/track-details.ts +++ b/frontend/src/components/track-details/track-details.ts @@ -13,6 +13,7 @@ import { formatFileSize, } from '@utils/format'; import { formatMilliseconds } from '@utils/time'; +import { nameDialogsIn } from '@utils/name-dialog'; import { WriteTrackTagsByPath } from '@go/tagwriter/TagWriter'; import { BatchWriteTrackTags, @@ -726,6 +727,15 @@ export class TrackDetails extends LitElement { // RENDER // ================================================================= + /** + * Web Awesome renders `label` into a heading it never points the + * `` at, so the dialog has no accessible name until + * something sets one. See `utils/name-dialog.ts`. + */ + override updated() { + nameDialogsIn(this.shadowRoot); + } + override render() { const label = this.batchMode ? 'Batch Edit' diff --git a/frontend/src/utils/name-dialog.ts b/frontend/src/utils/name-dialog.ts new file mode 100644 index 0000000..cefd72b --- /dev/null +++ b/frontend/src/utils/name-dialog.ts @@ -0,0 +1,121 @@ +/** + * Every `wa-dialog` in this app is an unnamed dialog. This names them. + * + * `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 + * reaches the accessibility tree. Web Awesome renders it into an + * `

` in the *same shadow root* as the native + * `` and never points `aria-labelledby` at it, so + * `getByRole('dialog', {name})` matches nothing and a screen reader + * announces an unnamed dialog. Confirmed in the installed source + * (`dist/chunks/chunk.ZUIYLL2X.js`, `WaDialog.render`). + * + * Three decisions are worth stating, because each has a failure mode: + * + * **This reaches into another library's shadow root**, which is open and + * therefore reachable, but is not API. It is acceptable here precisely + * because the failure is bounded: if Web Awesome changes the structure, + * the query returns null, the helper does nothing, and the dialog is as + * unnamed as it is today. There is no state to get wrong and nothing to + * throw. The alternative — patching `WaDialog.prototype` — fixes every + * call site for free and fails loudly and strangely instead. + * + * **It prefers `aria-labelledby` over `aria-label`.** Three call sites + * have a label that changes after the first render (`track-details`, + * `confirm-dialog`, `notification-host`), and an IDREF to the `

` the + * dialog re-renders anyway stays correct for free. `aria-label` is the + * fallback for `without-header`, which renders no `

` at all + * (`first-run-wizard` is the one such caller). + * + * **And it waits for the dialog's own first update, not its host's.** + * `wa-dialog` is a Lit element: its shadow root is populated in *its* + * update, which has not run when the host reaches `firstUpdated`. A + * synchronous query there finds an element with an empty shadow root + * and names nothing — the same trap that made `wa-dropdown-item`'s role + * invisible to the menu keyboard model two passes ago. + */ + +/** The id Web Awesome gives its title heading, within its own shadow root. */ +const WA_TITLE_ID = 'title'; + +/** A `wa-dialog` element, as much of it as this file needs. */ +type DialogHost = Element & { + label?: string; + updateComplete?: Promise; +}; + +/** + * Dialogs already pointed at their own heading. + * + * Hosts call this from `updated()`, which runs on every pass, so the + * steady-state cost has to be a lookup rather than an attribute write. + * Only the IDREF path is cached: it cannot go stale, because the `

` + * it names re-renders with the label. The `aria-label` fallback is + * re-applied every call, since a copied string can. + */ +const named = new WeakSet(); + +/** Set the name, if the shadow root is there to set it on. Returns success. */ +function applyName(host: DialogHost): boolean { + const root = host.shadowRoot; + + if (!root) return false; + + const dialog = root.querySelector('dialog[part~="dialog"]'); + + if (!dialog) return false; + + // The header, and so the heading the name comes from, is absent + // under `without-header`. + if (root.querySelector(`#${WA_TITLE_ID}`)) { + dialog.setAttribute('aria-labelledby', WA_TITLE_ID); + dialog.removeAttribute('aria-label'); + named.add(host); + + return true; + } + + const label = host.label ?? host.getAttribute('label') ?? ''; + + if (label) { + dialog.setAttribute('aria-label', label); + dialog.removeAttribute('aria-labelledby'); + } + + return true; +} + +/** + * Point the native `` inside a `` at its own title. + * + * Safe to call before the dialog has rendered, and safe to call more + * than once — it is a no-op when there is nothing to name. Call it from + * the host's `firstUpdated()`, or from `updated()` for a host that + * renders its dialog conditionally. + */ +export function nameDialog(host: DialogHost | null | undefined): void { + if (!host || named.has(host)) return; + + if (applyName(host)) return; + + // Not rendered yet: wait for the dialog's own update, then retry + // once. Anything still missing after that is a structure this + // helper does not recognise, which is the bounded failure above. + void host.updateComplete?.then(() => { + applyName(host); + }); +} + +/** + * Name every `wa-dialog` rendered in `root`. + * + * The hosts that render two dialogs, or render one conditionally, use + * this rather than tracking a `@query` each. + */ +export function nameDialogsIn(root: ShadowRoot | null | undefined): void { + if (!root) return; + + for (const host of root.querySelectorAll('wa-dialog')) { + nameDialog(host as DialogHost); + } +} diff --git a/frontend/test/components/dialog-names.test.ts b/frontend/test/components/dialog-names.test.ts new file mode 100644 index 0000000..fbf6960 --- /dev/null +++ b/frontend/test/components/dialog-names.test.ts @@ -0,0 +1,150 @@ +/** + * 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 + * `

` in the same shadow root as the native `` and + * never points `aria-labelledby` at it. + * + * What this tier can and cannot check is worth stating, because the + * distinction is the whole reason the e2e spec also exists. It queries + * shadow roots directly, so it can assert the *wiring* — the IDREF, and + * that it resolves to a heading carrying the label. It cannot compute an + * accessible name; `getByRole('dialog', {name})` is Playwright's job, + * and `e2e/specs/dialog-names.spec.ts` does it there. + */ +import { describe, expect, it } from 'vitest'; + +import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; +import { confirmAction } from '@components/confirm-dialog/confirm-dialog'; +import '@components/shortcuts-overlay/shortcuts-overlay'; +import { fixture, shadow } from '@test/support/render'; +import { nameDialog, nameDialogsIn } from '@utils/name-dialog'; + +/** The `` Web Awesome renders inside a ``. */ +function native(host: Element | null): HTMLElement | null { + return host?.shadowRoot?.querySelector('dialog[part~="dialog"]') + ?? null; +} + +/** What an IDREF on that dialog actually resolves to, in its own root. */ +function labelledByText(host: Element | null): string | null { + const dialog = native(host); + const id = dialog?.getAttribute('aria-labelledby'); + + if (!id) return null; + + return host?.shadowRoot?.getElementById(id)?.textContent?.trim() ?? null; +} + +/** Mount a bare `wa-dialog`, name it, and wait for both updates. */ +async function namedDialog( + attrs: Record, +): Promise { + const el = document.createElement('wa-dialog'); + + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + + document.body.append(el); + await (el as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + nameDialog(el); + await (el as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + return el; +} + +describe('naming a wa-dialog', () => { + it('is unnamed until something names it — which is the bug', async () => { + const el = document.createElement('wa-dialog'); + + el.setAttribute('label', 'Nobody announces this'); + document.body.append(el); + await (el as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + const dialog = native(el); + + expect(dialog).toBeTruthy(); + expect(dialog!.getAttribute('aria-labelledby')).toBeNull(); + expect(dialog!.getAttribute('aria-label')).toBeNull(); + + el.remove(); + }); + + it('points the dialog at the heading carrying the label', async () => { + const el = await namedDialog({ label: 'Duplicate Tracks Found' }); + + expect(labelledByText(el)).toBe('Duplicate Tracks Found'); + + el.remove(); + }); + + it('falls back to aria-label when there is no header to point at', async () => { + // `first-run-wizard` is the one caller using `without-header`, so + // the IDREF path has no `

` to name and the string is copied. + const el = await namedDialog({ + label: 'Welcome to YellowJacket', + 'without-header': '', + }); + + expect(native(el)!.getAttribute('aria-labelledby')).toBeNull(); + expect(native(el)!.getAttribute('aria-label')).toBe( + 'Welcome to YellowJacket', + ); + + el.remove(); + }); + + it('is a no-op on anything that is not a wa-dialog', () => { + // The bounded failure mode: if Web Awesome moves this structure the + // query misses, nothing is written, and the dialog is exactly as + // unnamed as it is today. + expect(() => nameDialog(document.createElement('div'))).not.toThrow(); + expect(() => nameDialogsIn(null)).not.toThrow(); + }); +}); + +describe('the dialogs the app actually opens', () => { + it('names confirm-dialog from the title the caller passed', async () => { + const answer = confirmAction({ + title: 'Remove “Live Sessions”?', + message: 'The library entry is removed; the files are not.', + }); + + const el = document.querySelector('confirm-dialog')!; + + await (el as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + const dialog = shadow(el, 'wa-dialog'); + + await (dialog as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + expect(labelledByText(dialog)).toBe('Remove “Live Sessions”?'); + + el.shadowRoot + ?.querySelector('[data-testid="confirm-cancel"]') + ?.click(); + await answer; + }); + + it('names the shortcuts overlay, which renders its dialog on demand', async () => { + const el = await fixture('shortcuts-overlay'); + + document.dispatchEvent(new CustomEvent('shortcut:app-shortcuts')); + await (el as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + const dialog = shadow(el, 'wa-dialog'); + + await (dialog as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + expect(labelledByText(dialog)).toBe('Keyboard Shortcuts'); + }); +});