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.
This commit is contained in:
@@ -18,7 +18,7 @@ here has disappeared.
|
|||||||
|
|
||||||
## Read this part before you fail
|
## 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.
|
reference, because you need them *before* the failure, not after.
|
||||||
|
|
||||||
- **Time out every binding call.** A bound Go method called with wrong
|
- **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 `<dialog>` in its
|
reports hidden; the visible thing is the native `<dialog>` in its
|
||||||
shadow root. The slotted content is in the *host's* shadow root, not
|
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
|
in that dialog's subtree, so `toContainText` on the dialog sees only
|
||||||
its chrome. And the dialog has **no accessible name** — Web Awesome
|
its chrome. And it has an accessible name **only because
|
||||||
never wires `label` to `aria-labelledby` — so
|
`utils/name-dialog.ts` gives it one** — Web Awesome does not wire
|
||||||
`getByRole('dialog', {name})` matches nothing.
|
`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).
|
- **Playwright's WebKit does not run on Arch** (Ubuntu-only libs).
|
||||||
`--browser=webkit` is CI-only; local work is Chromium. CI runs it
|
`--browser=webkit` is CI-only; local work is Chromium. CI runs it
|
||||||
with `if: !cancelled()` so a chromium failure does not silently
|
with `if: !cancelled()` so a chromium failure does not silently
|
||||||
|
|||||||
@@ -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
|
`autotag-view`'s last document keydown listener died with them; it
|
||||||
existed only because its dialogs could not close themselves.
|
existed only because its dialogs could not close themselves.
|
||||||
|
|
||||||
**None of them has an accessible name**, which is worth knowing before
|
**None of them had an accessible name, and one helper gives all of them
|
||||||
writing a locator or believing the audit's "already correct" list.
|
one.** Every call site passes `label`; Web Awesome renders it into an
|
||||||
Every call site passes `label`; Web Awesome renders it into an `<h2
|
`<h2 id="title">` in the same shadow root as the native `<dialog>` and
|
||||||
id="title">` in the same shadow root as the native `<dialog>` and never
|
never points `aria-labelledby` at it — so for eleven dialogs
|
||||||
points `aria-labelledby` at it, so `getByRole('dialog', {name})`
|
`getByRole('dialog', {name})` matched nothing and a screen reader
|
||||||
matches nothing and a screen reader announces an unnamed dialog. The
|
announced an unnamed dialog. `utils/name-dialog.ts` sets that IDREF
|
||||||
host is also `display: contents`, so the element carrying the testid
|
(and falls back to `aria-label` under `without-header`, which renders
|
||||||
always reports hidden — what is visible is the `<dialog>` inside it,
|
no heading to point at), called from each host's `updated()`.
|
||||||
and what holds the slotted content is the *host's* shadow root, not the
|
|
||||||
dialog's subtree.
|
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.**
|
**A disclosure is a button, and it says what it controls.**
|
||||||
`config-section`'s header was a bare `<div @click>` with no `tabindex`,
|
`config-section`'s header was a bare `<div @click>` with no `tabindex`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
* `<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();
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-di
|
|||||||
import { libraryStore } from '../../store/library-store';
|
import { libraryStore } from '../../store/library-store';
|
||||||
import { notificationStore } from '../../store/notification-store';
|
import { notificationStore } from '../../store/notification-store';
|
||||||
import { describeError, explainError } from '../../utils/describe-error';
|
import { describeError, explainError } from '../../utils/describe-error';
|
||||||
|
import { nameDialogsIn } from '../../utils/name-dialog';
|
||||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||||
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
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()}`;
|
return html`${this.renderPasteDialog()}${this.renderSearchDialog()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Awesome renders `label` into a heading it never points the
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
// The layout chrome always renders immediately; each pane owns
|
// The layout chrome always renders immediately; each pane owns
|
||||||
// its own skeleton while its data resolves, so the user never
|
// its own skeleton while its data resolves, so the user never
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { customElement, query, state } from 'lit/decorators.js';
|
|||||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||||
|
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { nameDialogsIn } from '../../utils/name-dialog';
|
||||||
|
|
||||||
export interface ConfirmRequest {
|
export interface ConfirmRequest {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -103,6 +104,15 @@ export class ConfirmDialog extends LitElement {
|
|||||||
settle?.(ok);
|
settle?.(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Awesome renders `label` into a heading it never points the
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const request = this.request;
|
const request = this.request;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { downloadStore } from '@store/download-store';
|
|||||||
import type { download } from '@go/models';
|
import type { download } from '@go/models';
|
||||||
import './candidate-row';
|
import './candidate-row';
|
||||||
import { explainError } from '@utils/describe-error';
|
import { explainError } from '@utils/describe-error';
|
||||||
|
import { nameDialogsIn } from '@utils/name-dialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "find this album" dialog: searches every enabled download client,
|
* The "find this album" dialog: searches every enabled download client,
|
||||||
@@ -119,6 +120,11 @@ export class DownloadPicker extends LitElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
override updated(changed: Map<string, unknown>) {
|
override updated(changed: Map<string, unknown>) {
|
||||||
|
// Web Awesome renders `label` into a heading it never points
|
||||||
|
// the `<dialog>` 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) {
|
if (changed.has('open') && this.open) {
|
||||||
void this.search();
|
void this.search();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|||||||
import '@awesome.me/webawesome/dist/components/switch/switch.js';
|
import '@awesome.me/webawesome/dist/components/switch/switch.js';
|
||||||
import { AddTracksToPlaylist } from '@go/playlist/Service';
|
import { AddTracksToPlaylist } from '@go/playlist/Service';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
import { formatMilliseconds } from '@utils/time';
|
||||||
|
import { nameDialogsIn } from '@utils/name-dialog';
|
||||||
|
|
||||||
interface DuplicateTrack {
|
interface DuplicateTrack {
|
||||||
FilePath: string;
|
FilePath: string;
|
||||||
@@ -271,6 +272,15 @@ export class DuplicateTracksDialog extends LitElement {
|
|||||||
// RENDER
|
// RENDER
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Awesome renders `label` into a heading it never points the
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const current = this.duplicates[this.currentIndex];
|
const current = this.duplicates[this.currentIndex];
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@go/library/Library';
|
} from '@go/library/Library';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||||
import { describeError, explainError } from '@utils/describe-error';
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
|
import { nameDialogsIn } from '@utils/name-dialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run setup wizard.
|
* First-run setup wizard.
|
||||||
@@ -164,6 +165,15 @@ export class FirstRunWizard extends LitElement {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Awesome renders `label` into a heading it never points the
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
if (!this.active) return nothing;
|
if (!this.active) return nothing;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
|||||||
|
|
||||||
import { notificationStore } from '@store/notification-store';
|
import { notificationStore } from '@store/notification-store';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { nameDialogsIn } from '../../utils/name-dialog';
|
||||||
|
|
||||||
import { noticeStyles, renderNotice } from './notice';
|
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
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const stacked = [
|
const stacked = [
|
||||||
...notificationStore.byLevel('persistent'),
|
...notificationStore.byLevel('persistent'),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@go/playlist/Service';
|
} from '@go/playlist/Service';
|
||||||
import type { playlist } from '@go/models';
|
import type { playlist } from '@go/models';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
import { formatMilliseconds } from '@utils/time';
|
||||||
|
import { nameDialogsIn } from '@utils/name-dialog';
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 400;
|
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
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
<wa-dialog
|
<wa-dialog
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { customElement, query, state } from 'lit/decorators.js';
|
|||||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||||
|
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { nameDialogsIn } from '../../utils/name-dialog';
|
||||||
import {
|
import {
|
||||||
SHORTCUT_CATEGORIES,
|
SHORTCUT_CATEGORIES,
|
||||||
SHORTCUT_META,
|
SHORTCUT_META,
|
||||||
@@ -158,6 +159,15 @@ export class ShortcutsOverlay extends LitElement {
|
|||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Awesome renders `label` into a heading it never points the
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
if (!this.isOpen) return nothing;
|
if (!this.isOpen) return nothing;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
formatFileSize,
|
formatFileSize,
|
||||||
} from '@utils/format';
|
} from '@utils/format';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
import { formatMilliseconds } from '@utils/time';
|
||||||
|
import { nameDialogsIn } from '@utils/name-dialog';
|
||||||
import { WriteTrackTagsByPath } from '@go/tagwriter/TagWriter';
|
import { WriteTrackTagsByPath } from '@go/tagwriter/TagWriter';
|
||||||
import {
|
import {
|
||||||
BatchWriteTrackTags,
|
BatchWriteTrackTags,
|
||||||
@@ -726,6 +727,15 @@ export class TrackDetails extends LitElement {
|
|||||||
// RENDER
|
// RENDER
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Awesome renders `label` into a heading it never points the
|
||||||
|
* `<dialog>` at, so the dialog has no accessible name until
|
||||||
|
* something sets one. See `utils/name-dialog.ts`.
|
||||||
|
*/
|
||||||
|
override updated() {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const label = this.batchMode
|
const label = this.batchMode
|
||||||
? 'Batch Edit'
|
? 'Batch Edit'
|
||||||
|
|||||||
@@ -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
|
||||||
|
* `<h2 part="title" id="title">` in the *same shadow root* as the native
|
||||||
|
* `<dialog part="dialog">` 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 `<h2>` the
|
||||||
|
* dialog re-renders anyway stays correct for free. `aria-label` is the
|
||||||
|
* fallback for `without-header`, which renders no `<h2>` 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<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `<h2>`
|
||||||
|
* 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<Element>();
|
||||||
|
|
||||||
|
/** 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 `<dialog>` inside a `<wa-dialog>` 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* `<h2 id="title">` in the same shadow root as the native `<dialog>` 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 `<dialog>` Web Awesome renders inside a `<wa-dialog>`. */
|
||||||
|
function native(host: Element | null): HTMLElement | null {
|
||||||
|
return host?.shadowRoot?.querySelector<HTMLElement>('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<string, string>,
|
||||||
|
): Promise<HTMLElement> {
|
||||||
|
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<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
nameDialog(el);
|
||||||
|
await (el as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.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<unknown> })
|
||||||
|
.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 `<h2>` 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<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
const dialog = shadow(el, 'wa-dialog');
|
||||||
|
|
||||||
|
await (dialog as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
expect(labelledByText(dialog)).toBe('Remove “Live Sessions”?');
|
||||||
|
|
||||||
|
el.shadowRoot
|
||||||
|
?.querySelector<HTMLButtonElement>('[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<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
const dialog = shadow(el, 'wa-dialog');
|
||||||
|
|
||||||
|
await (dialog as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
expect(labelledByText(dialog)).toBe('Keyboard Shortcuts');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user