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
@@ -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
* `<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() {
// The layout chrome always renders immediately; each pane owns
// 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 { 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
* `<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() {
const request = this.request;
@@ -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<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) {
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 { 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
* `<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() {
const current = this.duplicates[this.currentIndex];
@@ -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
* `<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() {
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 { 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
* `<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() {
const stacked = [
...notificationStore.byLevel('persistent'),
@@ -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
* `<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() {
return html`
<wa-dialog
@@ -21,6 +21,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';
import {
SHORTCUT_CATEGORIES,
SHORTCUT_META,
@@ -158,6 +159,15 @@ export class ShortcutsOverlay extends LitElement {
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() {
if (!this.isOpen) return nothing;
@@ -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
* `<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() {
const label = this.batchMode
? 'Batch Edit'