From 855ec70d2732d09cc4eb5e2aba43ba884ac8630b Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 14 Aug 2026 21:58:31 +0530 Subject: [PATCH 01/11] test: read the ui a11y tree over CDP to settle the two #1080 findings The two findings #1080 marked needs-runtime-check could not be settled by a DOM assertion, which is what ui-a11y.test.js already does: an attribute assertion re-checks the input and never the computed output. What a screen reader consumes is the platform accessibility tree, so assert against that directly, read over CDP from real Chromium. This is the measurement instrument, added before any fix so the fixes are decided by what it observes rather than by what the findings feared. --- .github/workflows/ci.yml | 6 + packages/ui/package.json | 3 +- packages/ui/test/e2e/a11y-tree.e2e.mjs | 342 +++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 packages/ui/test/e2e/a11y-tree.e2e.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab3f99fde..12313db5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -486,6 +486,12 @@ jobs: # under a Chromium iPhone context (faithful touch events, no real device). - name: Run ui touch e2e run: npm run test:e2e:touch --workspace=@webjsdev/ui + # Accessibility-tree e2e for the ui dialog / alert-dialog / sonner ARIA + # (#1080/#1245): reads Chrome's computed accessibility tree over CDP, which + # is what a screen reader consumes, rather than re-checking the attributes + # the components wrote. Headless, so no display server is needed. + - name: Run ui a11y-tree e2e + run: npm run test:e2e:a11y-tree --workspace=@webjsdev/ui # Cross-runtime e2e (#523), split into its OWN job (#774) so it runs in # PARALLEL with the Node-served e2e above instead of as a trailing step diff --git a/packages/ui/package.json b/packages/ui/package.json index 0c26532dc..20218bdbc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,7 +21,8 @@ ], "scripts": { "test": "node --test test/*.test.js", - "test:e2e:touch": "node test/e2e/touch.e2e.mjs" + "test:e2e:touch": "node test/e2e/touch.e2e.mjs", + "test:e2e:a11y-tree": "node test/e2e/a11y-tree.e2e.mjs" }, "dependencies": { "commander": "^14.0.0", diff --git a/packages/ui/test/e2e/a11y-tree.e2e.mjs b/packages/ui/test/e2e/a11y-tree.e2e.mjs new file mode 100644 index 000000000..ced11c837 --- /dev/null +++ b/packages/ui/test/e2e/a11y-tree.e2e.mjs @@ -0,0 +1,342 @@ +/** + * Accessibility-tree e2e for the two needs-runtime-check findings from #1080 + * (tracked by #1245). + * + * Those two findings could not be settled by a DOM assertion, which is what + * `test/components/browser/ui-a11y.test.js` already does: an attribute + * assertion re-checks the INPUT and never the computed OUTPUT. What a screen + * reader consumes is the platform accessibility tree, so this file asserts + * against that tree directly, read over CDP from real Chromium. + * + * Finding 1, double-role nesting: dialog and alert-dialog each render a native + * `` (implicit `role=dialog`) wrapping an inner div that carried the + * ARIA. Two nested dialog-ish roles risk the outer one being what assistive + * tech reports, which for alert-dialog would drop the `alertdialog` role and + * the more urgent announcement it triggers. The assertions below pin exactly + * ONE dialog-family node in the chain, and pin which role it carries. + * + * Finding 2, nested live regions: the sonner viewport is a persistent polite + * live region and each toast carried its own `role="status"` / `role="alert"`, + * both implicit live regions. The assertions pin how many live roots a toast + * resolves under, and the politeness of each. + * + * What this file does NOT answer is whether a reader SPEAKS a toast twice. + * That is the reader's announcement queue, one layer above the tree, and it is + * not a browser artifact. It stays a manual pass, recorded on #1245. + * + * Two CDP facts worth not re-deriving by trial and error: + * - `properties` is an ARRAY of `{ name, value: { type, value } }`, with key + * names `live` / `atomic` / `relevant` / `modal` / `focusable`. + * - An open modal DROPS every node outside the top layer from the tree + * entirely. They are absent, not returned with `ignored: true`. So probe + * sonner with no dialog open, and probe a dialog only while it is open. + * + * Self-contained: boots the site, runs the checks, tears down. Needs Playwright + * plus a browser. Run: `node packages/ui/test/e2e/a11y-tree.e2e.mjs`. Skips with + * a clear message if Playwright or a browser is unavailable. + */ +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const WEBSITE = resolve(HERE, '../../../../website'); +const PORT = Number(process.env.WEBJS_E2E_A11Y_PORT || 5182); +const BASE = `http://localhost:${PORT}`; + +function fail(msg) { console.error('FAIL: ' + msg); process.exitCode = 1; } + +let pw; +try { + pw = (await import('playwright')).default ?? (await import('playwright')); +} catch { + console.log('SKIP a11y-tree e2e: playwright not installed.'); + process.exit(0); +} + +// Boot the site (mirrors the registry sources in, then `webjs start`). +const cli = resolve(WEBSITE, '../node_modules/@webjsdev/cli/bin/webjs.js'); +spawn(process.execPath, [resolve(WEBSITE, 'scripts/copy-registry.mjs')], { cwd: WEBSITE, stdio: 'ignore' }); +await sleep(800); +const server = spawn(process.execPath, [cli, 'start', '--port', String(PORT)], { + cwd: WEBSITE, + stdio: 'ignore', + env: { ...process.env, WEBJS_E2E_A11Y: '1' }, +}); +const teardown = () => { try { server.kill('SIGTERM'); } catch { /* ignore */ } }; +process.on('exit', teardown); + +// Wait for readiness. +let up = false; +for (let i = 0; i < 40; i++) { + try { + const r = await fetch(BASE + '/__webjs/ready').catch(() => null); + if (r && r.ok) { up = true; break; } + } catch { /* retry */ } + await sleep(500); +} +if (!up) { fail('the gallery site did not become ready'); teardown(); process.exit(1); } + +// Headless is verified to return a fully populated tree, so CI needs no display +// server. A desktop context, NOT the iPhone descriptor `touch.e2e.mjs` uses: +// this file is testing the accessibility tree, not touch. +let browser; +try { + browser = await pw.chromium.launch({ headless: true }); +} catch (e) { + console.log('SKIP a11y-tree e2e: could not launch Chromium (' + String(e.message).split('\n')[0] + ').'); + teardown(); + process.exit(0); +} + +const ctx = await browser.newContext(); +const page = await ctx.newPage(); +const results = []; +const notes = []; + +const cdp = await ctx.newCDPSession(page); +await cdp.send('Accessibility.enable'); +await cdp.send('DOM.enable'); + +const DIALOG_ROLES = new Set(['dialog', 'alertdialog']); + +/** A named property's raw value off an AXNode, or undefined. */ +const propOf = (node, name) => (node.properties || []).find((p) => p.name === name)?.value?.value; + +/** backendDOMNodeId for a CSS selector, or null when the selector misses. */ +async function backendIdFor(selector) { + const { root } = await cdp.send('DOM.getDocument', { depth: -1 }); + const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector }); + if (!nodeId) return null; + const { node } = await cdp.send('DOM.describeNode', { nodeId }); + return node.backendNodeId; +} + +/** + * AX chain from the element matching `selector` up to the root, nearest first. + * Walks `parentId` / `nodeId`, which are AX-tree-local strings; the numeric + * `backendDOMNodeId` is only how a DOM node crosses into its AX node. + */ +async function axChain(selector) { + const backendId = await backendIdFor(selector); + if (backendId == null) return null; + const { nodes } = await cdp.send('Accessibility.getFullAXTree'); + const byId = new Map(nodes.map((n) => [n.nodeId, n])); + let cur = nodes.find((n) => n.backendDOMNodeId === backendId); + if (!cur) return null; + const chain = []; + while (cur) { chain.push(cur); cur = cur.parentId ? byId.get(cur.parentId) : null; } + return chain; +} + +/** A null chain is a FAILED check, never a skipped one. */ +function chainMissing(label, selector) { + results.push([label + ' (selector did not resolve to an AX node: ' + selector + ')', false]); +} + +const dialogNodesIn = (chain) => chain.filter((n) => DIALOG_ROLES.has(n.role?.value) && !n.ignored); +const liveNodesIn = (chain) => chain.filter((n) => propOf(n, 'live') !== undefined); + +// --------------------------------------------------------------------------- +// 1) dialog, titled path. The gallery title is an

with no +// data-slot="dialog-title", so the name resolves through wireDialogLabels()'s +// `h1, h2, h3` fallback. That is the common path, and the one being asserted. +// --------------------------------------------------------------------------- +await page.goto(BASE + '/ui/dialog', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(1800); +await page.evaluate(() => { + const btn = [...document.querySelectorAll('ui-dialog-trigger button')] + .find((b) => /open dialog/i.test(b.textContent || '')); + btn?.click(); +}); +await page.waitForTimeout(700); + +const DIALOG_PANEL = 'dialog[data-slot="dialog-native"][open] [data-slot="dialog-content"]'; +{ + const chain = await axChain(DIALOG_PANEL); + if (!chain) { + chainMissing('dialog exposes exactly one dialog-family node', DIALOG_PANEL); + } else { + const dlgs = dialogNodesIn(chain); + notes.push('dialog: ' + dlgs.length + ' dialog-family node(s), roles=[' + + dlgs.map((n) => n.role?.value).join(', ') + '], name=' + + JSON.stringify(dlgs[0]?.name?.value ?? null)); + results.push(['dialog exposes exactly one dialog-family node', dlgs.length === 1]); + results.push(['dialog takes its name from the title', dlgs[0]?.name?.value === 'Edit profile']); + results.push(['dialog is exposed as modal', propOf(dlgs[0], 'modal') === true]); + } +} + +// --------------------------------------------------------------------------- +// 2) dialog, title-less path. Built in the page rather than added to the +// gallery, so no registry or website surface changes. Asserts the #1230 +// generic-name fallback still lands on whichever node owns the role. +// --------------------------------------------------------------------------- +// Append and open in SEPARATE steps. The host defers its open to a microtask +// and needs `` upgraded and rendered first, so appending and +// calling show() in one evaluate opens nothing. +await page.evaluate(() => { + document.querySelector('ui-dialog')?.hide?.(); + const host = document.createElement('ui-dialog'); + host.id = 'untitled-probe'; + host.innerHTML = '

No title here.

'; + document.body.appendChild(host); +}); +await page.waitForTimeout(400); +await page.evaluate(() => document.getElementById('untitled-probe')?.show?.()); +await page.waitForTimeout(700); + +const UNTITLED_DIALOG_PANEL = '#untitled-probe dialog[data-slot="dialog-native"][open] [data-slot="dialog-content"]'; +{ + const chain = await axChain(UNTITLED_DIALOG_PANEL); + if (!chain) { + chainMissing('title-less dialog exposes exactly one dialog-family node', UNTITLED_DIALOG_PANEL); + } else { + const dlgs = dialogNodesIn(chain); + notes.push('dialog (title-less): ' + dlgs.length + ' dialog-family node(s), name=' + + JSON.stringify(dlgs[0]?.name?.value ?? null)); + results.push(['title-less dialog exposes exactly one dialog-family node', dlgs.length === 1]); + results.push(['title-less dialog falls back to the generic name', dlgs[0]?.name?.value === 'Dialog']); + } +} +await page.evaluate(() => document.getElementById('untitled-probe')?.remove()); + +// --------------------------------------------------------------------------- +// 3) alert-dialog, titled path. The role assertion is this finding's headline +// question: if the platform reports `dialog`, the urgency is lost AND the user +// is told they can dismiss it with Escape, which alert-dialog blocks by design. +// --------------------------------------------------------------------------- +await page.goto(BASE + '/ui/alert-dialog', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(1800); +await page.evaluate(() => { + const btn = [...document.querySelectorAll('ui-alert-dialog-trigger button')] + .find((b) => /delete account/i.test(b.textContent || '')); + btn?.click(); +}); +await page.waitForTimeout(700); + +const ALERT_PANEL = 'dialog[data-slot="alert-dialog-native"][open] [data-slot="alert-dialog-content"]'; +{ + const chain = await axChain(ALERT_PANEL); + if (!chain) { + chainMissing('alert-dialog exposes exactly one dialog-family node', ALERT_PANEL); + } else { + const dlgs = dialogNodesIn(chain); + notes.push('alert-dialog: ' + dlgs.length + ' dialog-family node(s), roles=[' + + dlgs.map((n) => n.role?.value).join(', ') + '], name=' + + JSON.stringify(dlgs[0]?.name?.value ?? null)); + results.push(['alert-dialog exposes exactly one dialog-family node', dlgs.length === 1]); + results.push(['alert-dialog is exposed as alertdialog', dlgs[0]?.role?.value === 'alertdialog']); + results.push(['alert-dialog takes its name from the title', dlgs[0]?.name?.value === 'Are you sure?']); + results.push(['alert-dialog is exposed as modal', propOf(dlgs[0], 'modal') === true]); + } +} + +// Close it by calling hide(), NEVER by pressing Escape: alert-dialog blocks the +// native `cancel` event by design, so Escape does nothing and the next +// navigation would run with a modal still open, which empties the tree. +await page.evaluate(() => document.querySelector('ui-alert-dialog')?.hide?.()); +await page.waitForTimeout(300); + +// --------------------------------------------------------------------------- +// 4) alert-dialog, title-less path. +// --------------------------------------------------------------------------- +await page.evaluate(() => { + const host = document.createElement('ui-alert-dialog'); + host.id = 'untitled-alert-probe'; + host.innerHTML = '

No title here.

'; + document.body.appendChild(host); +}); +await page.waitForTimeout(400); +await page.evaluate(() => document.getElementById('untitled-alert-probe')?.show?.()); +await page.waitForTimeout(700); + +const UNTITLED_ALERT_PANEL = '#untitled-alert-probe dialog[data-slot="alert-dialog-native"][open] [data-slot="alert-dialog-content"]'; +{ + const chain = await axChain(UNTITLED_ALERT_PANEL); + if (!chain) { + chainMissing('title-less alert-dialog exposes exactly one dialog-family node', UNTITLED_ALERT_PANEL); + } else { + const dlgs = dialogNodesIn(chain); + notes.push('alert-dialog (title-less): ' + dlgs.length + ' dialog-family node(s), name=' + + JSON.stringify(dlgs[0]?.name?.value ?? null)); + results.push(['title-less alert-dialog exposes exactly one dialog-family node', dlgs.length === 1]); + results.push(['title-less alert-dialog is exposed as alertdialog', dlgs[0]?.role?.value === 'alertdialog']); + results.push(['title-less alert-dialog falls back to the generic name', dlgs[0]?.name?.value === 'Alert dialog']); + } +} +await page.evaluate(() => { + document.querySelector('#untitled-alert-probe')?.hide?.(); + document.getElementById('untitled-alert-probe')?.remove(); +}); +await page.waitForTimeout(300); + +// --------------------------------------------------------------------------- +// 5) sonner. No dialog open here, per the top-layer note above. +// +// Fire through the served module rather than by matching button text: the +// gallery's own buttons import exactly this path, so it is a dependency the +// gallery already has, while the labels are template-generated strings a copy +// edit can change. Locate each toast by its own text, since the page mounts +// several viewports and the last to connect wins. +// --------------------------------------------------------------------------- +await page.goto(BASE + '/ui/sonner', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(1800); + +async function probeToast(label, fire, probeId, expectAssertive) { + await page.evaluate(fire); + await page.waitForTimeout(700); + const tagged = await page.evaluate(({ id, text }) => { + const el = [...document.querySelectorAll('[data-slot="sonner-toast"]')] + .find((t) => (t.textContent || '').includes(text)); + if (!el) return false; + el.id = id; + return true; + }, { id: probeId, text: label }); + if (!tagged) { results.push([label + ' toast rendered', false]); return; } + + const chain = await axChain('#' + probeId); + if (!chain) { chainMissing(label + ' toast resolves its live roots', '#' + probeId); return; } + const lives = liveNodesIn(chain); + const nearest = lives[0]; + notes.push(label + ' toast: ' + lives.length + ' live root(s), politeness=[' + + lives.map((n) => propOf(n, 'live')).join(', ') + ']'); + + if (expectAssertive) { + // An error toast keeps role="alert" on purpose, so it resolves under TWO + // roots both before and after the sonner fix. Assert the contract that + // holds in both worlds, and record the observed count in the note above. + results.push([label + ' toast has at least one live root', lives.length >= 1]); + results.push([label + ' toast is assertive at its nearest live root', propOf(nearest, 'live') === 'assertive']); + } else { + results.push([label + ' toast resolves under exactly one live root', lives.length === 1]); + results.push([label + ' toast is polite at that root', propOf(nearest, 'live') === 'polite']); + } +} + +await probeToast( + 'Default toast probe', + () => import('/modules/ui/components/sonner.ts').then((m) => m.toast('Default toast probe')), + 'default-toast-probe', + false, +); +await probeToast( + 'Error toast probe', + () => import('/modules/ui/components/sonner.ts').then((m) => m.toast.error('Error toast probe')), + 'error-toast-probe', + true, +); + +await browser.close(); +teardown(); + +for (const n of notes) console.log('NOTE: ' + n); +let ok = true; +for (const [name, pass] of results) { + console.log((pass ? 'PASS' : 'FAIL') + ': ' + name); + if (!pass) { ok = false; fail(name); } +} +if (ok) console.log('a11y-tree e2e: all ' + results.length + ' checks passed'); +process.exit(ok ? 0 : 1); From 46ddcce1206c170aa2ec961ad82939d4c2f2c5b6 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 14 Aug 2026 22:05:23 +0530 Subject: [PATCH 02/11] fix: expose one dialog role and one live root per toast in ui Both findings the #1080 audit marked needs-runtime-check are real, measured against Chrome's computed accessibility tree rather than guessed at. dialog and alert-dialog each exposed TWO nested dialog-family nodes, because the native has an implicit role=dialog and the inner content div carried the ARIA. The role, the name target and the tab stop move onto the native element, so exactly one node is exposed. aria-modal goes with them: a showModal()-opened native dialog is already exposed as modal by the platform, which the tree confirms both before and after. An ordinary toast resolved under two nested live roots, its own role=status and the polite viewport. The inner role bought nothing, since the viewport is already polite, so a non-error toast now carries no role. An error toast keeps role=alert, which is load-bearing: it is the only way to make one item assertive inside a polite viewport, and its second live root is the accepted cost. The attribute is branched rather than written as a nullish hole, which would serve role="" from the server renderer. One correction to the audit's reasoning: it feared the outer implicit role=dialog would mask the alertdialog role. Chrome exposes both, nearest first, with alertdialog nearest, so the urgency was not being lost. The redundant wrapper was still worth removing. --- packages/ui/AGENTS.md | 15 +++-- .../registry/components/alert-dialog.ts | 14 +++-- .../ui/packages/registry/components/dialog.ts | 13 ++-- .../ui/packages/registry/components/sonner.ts | 60 ++++++++++++++----- .../test/components/browser/ui-a11y.test.js | 36 ++++++++--- .../components/browser/ui-overlay.test.js | 10 +++- packages/ui/test/registry-contents.test.js | 7 ++- packages/ui/test/ssr-aria.test.js | 49 +++++++++++++++ 8 files changed, 161 insertions(+), 43 deletions(-) diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 4dfd3d86c..6ae5ea39b 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -181,13 +181,13 @@ tracked source, the standard shadcn "you own it" pattern. | 1b | `collapsible` | `collapsibleClass`, `collapsibleTriggerClass`, `collapsibleContentClass`. Compose with `
` + ``. | | 1b | `progress` | `progressClass()`, apply to native ``. Browser draws the bar via `::-webkit-progress-value` and `::-moz-progress-bar`. Omit `value` for the indeterminate / pulse state. | | 2 | `toggle-group` | `` + ``. Roving tabindex (one Tab stop) with Arrow / Home / End navigation, plus `aria-pressed` per item. A `disabled` item reports `aria-disabled`, refuses activation, and is skipped by navigation and by the tab stop. | -| 2 | `dialog` | `` + `` / `` / ``. Built on native `.showModal()`, top-layer rendering, ::backdrop overlay, focus trap, Escape close, and focus restoration are all platform-provided. We add a scroll lock (refcounted, and shift-free for a `position: fixed` header, see invariant 5) + class helpers for `dialogHeader/Title/Description/Footer`. On open it wires `aria-labelledby` / `aria-describedby` to the `data-slot="dialog-title"` / `dialog-description` nodes (falling back to the first heading / paragraph). | -| 2 | `alert-dialog` | Like dialog, role=alertdialog. Native Escape close is cancelled via the `cancel` event; no backdrop-click dismissal. `` / ``. Wires `aria-labelledby` / `aria-describedby` to its `alert-dialog-title` / `alert-dialog-description` the same way. | +| 2 | `dialog` | `` + `` / `` / ``. Built on native `.showModal()`, top-layer rendering, ::backdrop overlay, focus trap, Escape close, and focus restoration are all platform-provided. We add a scroll lock (refcounted, and shift-free for a `position: fixed` header, see invariant 5) + class helpers for `dialogHeader/Title/Description/Footer`. On open it wires `aria-labelledby` / `aria-describedby` to the `data-slot="dialog-title"` / `dialog-description` nodes (falling back to the first heading / paragraph), onto the native ``, which is also where `role="dialog"` sits so exactly ONE dialog-family node is exposed. There is no `aria-modal`: a `showModal()`-opened native dialog is already exposed as modal by the platform. | +| 2 | `alert-dialog` | Like dialog, `role=alertdialog` on the native ``. Native Escape close is cancelled via the `cancel` event; no backdrop-click dismissal. `` / ``. Wires `aria-labelledby` / `aria-describedby` to its `alert-dialog-title` / `alert-dialog-description` the same way. | | 2 | `tooltip` | ``, hover/focus + delay. Content uses `popover="manual"` for top-layer rendering. The trigger references the tip via `aria-describedby` (APG tooltip wiring). Escape dismisses a showing tip without moving focus; a closed tip never consumes Escape. | | 2 | `hover-card` | ``, hover with linger-keep-open, mirrored for focus so in-card content is Tab-reachable. Content uses `popover="manual"` for top-layer rendering. The trigger (focusable, also opens on focus) gets `aria-haspopup` / `aria-expanded` / `aria-controls`; the `role="dialog"` panel is always named (author name, then a title node, then the trigger) and Escape dismisses it, returning focus to the trigger. | | 2 | `tabs` | `` + List / Trigger / Content. Arrow / Home / End move focus AND selection via a roving tabindex (one Tab stop). Triggers carry `aria-controls`, panels `aria-labelledby` (cross-linked per group), the list `aria-orientation`, and an inactive panel is `inert`. | | 2 | `dropdown-menu` | `` + Trigger / Content / Item (variant, `type="checkbox"` / `type="radio"` + `checked` / `value`) / Label / Separator / Shortcut / Group. Content uses `popover="manual"` for top-layer rendering. ArrowUp/Down nav, Home/End, typeahead, and synthesized Enter / Space activation (a `div[role=menuitem]` gets none natively). Escape closes the menu holding focus (a submenu first) and Tab closes and moves on; both return focus to the trigger, as do item activation and an outside click that did not itself land focus. Each submenu panel is named by its sub-trigger. Menu declares `aria-orientation`, a `data-disabled` item reflects `aria-disabled`, a checkable item carries `menuitemcheckbox` / `menuitemradio` + `aria-checked`, and the trigger gets `aria-haspopup` / `aria-expanded` / `aria-controls`. Emits a cancelable `ui-item-select`. | -| 2 | `sonner` | `` + `toast()` / `toast.success` / `toast.error` / `toast.promise` API, with `action` and `cancel` per toast. The viewport is a persistent `aria-live` region so inserted toasts are announced (an `error` toast is `role=alert`), and every toast carries a labelled close button so even a never-auto-dismissing `toast.loading()` can be dismissed by hand. | +| 2 | `sonner` | `` + `toast()` / `toast.success` / `toast.error` / `toast.promise` API, with `action` and `cancel` per toast. The viewport is a persistent polite `aria-live` region so inserted toasts are announced, and it is the ONLY live root an ordinary toast resolves under (an `error` toast additionally carries `role=alert`, which is the only way to make one item assertive inside a polite viewport, so it accepts a second live root; a non-error toast carries no role of its own). Every toast carries a labelled close button so even a never-auto-dismissing `toast.loading()` can be dismissed by hand. | ## Accessibility @@ -207,13 +207,16 @@ an outside click that did not itself put focus somewhere), synthesizes Enter / Space activation because a `div[role=menuitem]` gets none natively, names each submenu panel from its sub-trigger, and exposes `menuitemcheckbox` / `menuitemradio` + `aria-checked` for a -`type="checkbox"` / `type="radio"` item; dialog and alert-dialog name -themselves from their title and description on open, falling back to a generic +`type="checkbox"` / `type="radio"` item; dialog and alert-dialog carry their +role on the native `` (so exactly one dialog-family node is exposed, +verified against the computed accessibility tree) and name themselves from +their title and description on open, falling back to a generic `aria-label` so an unnamed modal is impossible; tooltip references its tip with `aria-describedby` and dismisses on Escape; hover-card exposes the popup relationship on its (focus-openable) trigger, always names its `role="dialog"` panel, dismisses on Escape, and keeps itself open while focus is inside so its -content is Tab-reachable; sonner is a persistent `aria-live` region whose every +content is Tab-reachable; sonner is a persistent polite `aria-live` region that +is the only live root an ordinary toast resolves under, and whose every toast carries a labelled close button. Do not hand-add these attributes; the element already has. diff --git a/packages/ui/packages/registry/components/alert-dialog.ts b/packages/ui/packages/registry/components/alert-dialog.ts index 6416a8def..d24132856 100644 --- a/packages/ui/packages/registry/components/alert-dialog.ts +++ b/packages/ui/packages/registry/components/alert-dialog.ts @@ -55,6 +55,11 @@ * Treat that as a bug in your markup rather than a feature: this dialog * interrupts the user to demand an explicit choice and blocks Escape, so * naming it "Alert dialog" tells them nothing about what they are deciding. + * `role="alertdialog"` and the name both sit on the NATIVE ``, a + * valid ARIA-in-HTML override of that element's implicit `dialog` role, so + * exactly one dialog-family node is exposed rather than an `alertdialog` + * nested inside a `dialog`. There is no `aria-modal`: the platform already + * exposes a `showModal()`-opened dialog as modal. * * Design tokens used: --background, --border, --muted-foreground. * @@ -425,10 +430,10 @@ export class UiAlertDialogContent extends WebComponent({ } _wireLabels(): void { - const panel = this.querySelector('[data-slot="alert-dialog-content"]'); + const panel = this.querySelector('dialog[data-slot="alert-dialog-native"]'); if (!panel) return; // A name the author put on is where they - // naturally write it, but role="alertdialog" lives on the inner panel. + // naturally write it, but role="alertdialog" lives on the native . // // Each authored-name branch RETURNS rather than falling through to the // title wiring. Falling through would set aria-labelledby from the title @@ -472,15 +477,14 @@ export class UiAlertDialogContent extends WebComponent({ const parentOpen = !!this._parent()?.open; return html`
`, not on the + * inner content div, so exactly one dialog-family node is exposed in the + * accessibility tree. There is no `aria-modal`: a `showModal()`-opened + * native dialog is already exposed as modal by the platform. * * Design tokens used: --background, --border, --muted-foreground. * @@ -122,7 +126,7 @@ export function wireDialogLabels(host: Element, panelSelector: string): void { const panel = host.querySelector(panelSelector); if (!panel) return; // A name the author put on is where they naturally write - // it, but role="dialog" lives on the inner panel, so forward it there. + // it, but role="dialog" lives on the native , so forward it there. // // This RETURNS once an authored name is forwarded, rather than falling // through to the title wiring below. Falling through would set @@ -505,7 +509,7 @@ export class UiDialogContent extends WebComponent({ } showModal(): void { - wireDialogLabels(this, '[data-slot="dialog-content"]'); + wireDialogLabels(this, 'dialog[data-slot="dialog-native"]'); const native = this._native(); if (native && !native.open) native.showModal(); } @@ -520,15 +524,14 @@ export class UiDialogContent extends WebComponent({ const parentOpen = !!this._parent()?.open; return html`