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/blog/accessible-web-components-by-default.md b/blog/accessible-web-components-by-default.md index 86d0eccad..181a794ea 100644 --- a/blog/accessible-web-components-by-default.md +++ b/blog/accessible-web-components-by-default.md @@ -69,9 +69,9 @@ The rendered output carries `role="tablist"`, `role="tab"` with `aria-selected` `` follows the WAI-ARIA Authoring Practices tabs pattern (the source links the spec URL directly). The list gets `role="tablist"` with `aria-orientation`, each trigger is a native ` - `, - )} - `; + `; } } UiSonner.register('ui-sonner'); @@ -316,8 +349,9 @@ UiSonner.register('ui-sonner'); const CLOSE_SVG = ''; -// Type glyphs, all decorative: the toast's text carries the meaning, and the -// type is conveyed by role="alert" vs role="status", not by the picture. +// Type glyphs, all decorative: the toast's text carries the meaning, and +// urgency is conveyed by role="alert" on an error toast, not by the picture. +// An ordinary toast carries no role and is announced by the polite viewport. const ICONS: Record = { default: '', success: diff --git a/packages/ui/test/components/browser/ui-a11y.test.js b/packages/ui/test/components/browser/ui-a11y.test.js index 1df9a61a7..63676d6fc 100644 --- a/packages/ui/test/components/browser/ui-a11y.test.js +++ b/packages/ui/test/components/browser/ui-a11y.test.js @@ -947,7 +947,7 @@ suite('ui-dialog a11y', () => { root.querySelector('ui-dialog').show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="dialog-native"]'); const title = root.querySelector('[data-slot="dialog-title"]'); const desc = root.querySelector('[data-slot="dialog-description"]'); assert.ok(title.id, 'title got an id'); @@ -971,7 +971,7 @@ suite('ui-dialog a11y', () => { root.querySelector('ui-dialog').show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="dialog-native"]'); assert.equal(panel.hasAttribute('aria-labelledby'), false, 'nothing to point at'); assert.equal(panel.getAttribute('aria-label'), 'Dialog', 'generic name as the floor'); root.querySelector('ui-dialog').hide(); @@ -1002,7 +1002,7 @@ suite('ui-dialog a11y', () => { dlg.show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="dialog-native"]'); const desc = root.querySelector('[data-slot="dialog-description"]'); assert.equal(panel.getAttribute('aria-label'), 'Edit profile', 'author name applied'); assert.equal( @@ -1032,7 +1032,7 @@ suite('ui-dialog a11y', () => { dlg.show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="dialog-native"]'); assert.equal(panel.getAttribute('aria-labelledby'), 'dlg-own-label', 'author reference wins'); } finally { dlg.hide(); @@ -1051,7 +1051,7 @@ suite('ui-dialog a11y', () => { root.querySelector('ui-dialog').show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="dialog-native"]'); assert.equal(panel.getAttribute('aria-label'), 'Edit profile', 'author name forwarded'); root.querySelector('ui-dialog').hide(); root.remove(); @@ -1075,7 +1075,7 @@ suite('ui-alert-dialog a11y', () => { root.querySelector('ui-alert-dialog').show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="alert-dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="alert-dialog-native"]'); const title = root.querySelector('[data-slot="alert-dialog-title"]'); const desc = root.querySelector('[data-slot="alert-dialog-description"]'); assert.ok(title.id); @@ -1098,7 +1098,7 @@ suite('ui-alert-dialog a11y', () => { root.querySelector('ui-alert-dialog').show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="alert-dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="alert-dialog-native"]'); assert.equal(panel.hasAttribute('aria-labelledby'), false, 'nothing to point at'); assert.equal(panel.getAttribute('aria-label'), 'Alert dialog', 'generic name as the floor'); root.querySelector('ui-alert-dialog').hide(); @@ -1121,7 +1121,7 @@ suite('ui-alert-dialog a11y', () => { dlg.show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="alert-dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="alert-dialog-native"]'); const desc = root.querySelector('[data-slot="alert-dialog-description"]'); assert.equal(panel.getAttribute('aria-label'), 'Confirm deletion'); assert.equal( @@ -1147,7 +1147,7 @@ suite('ui-alert-dialog a11y', () => { root.querySelector('ui-alert-dialog').show(); await tick(); await tick(); - const panel = root.querySelector('[data-slot="alert-dialog-content"]'); + const panel = root.querySelector('dialog[data-slot="alert-dialog-native"]'); assert.equal(panel.getAttribute('aria-label'), 'Confirm deletion', 'author name forwarded'); root.querySelector('ui-alert-dialog').hide(); root.remove(); @@ -1476,6 +1476,24 @@ suite('ui-sonner a11y', () => { root.remove(); }); + // #1245: an ordinary toast carries NO role of its own. role="status" is + // itself a live region, so it resolved under TWO nested live roots (measured + // over CDP) and some readers double-announce that, while buying nothing: + // the viewport is already polite. Asserting the ABSENCE of the attribute + // matters more than it looks. The template branches the whole attribute + // rather than emitting a nullish hole, because a nullish hole serves + // role="" from the server renderer, and an empty role is not a role. + test('an ordinary toast carries no role of its own, and no empty one', async () => { + const root = await mount(html``); + root.querySelector('ui-sonner').addToast('Saved', {}); + await tick(); + const toast = root.querySelector('[data-slot="sonner-toast"]'); + assert.ok(toast, 'the toast rendered'); + assert.equal(toast.hasAttribute('role'), false, 'no role attribute at all'); + assert.equal(toast.getAttribute('role'), null, 'and not an empty one'); + root.remove(); + }); + // Finding 7: render() emitted no close button, so a toast could only leave via // its auto-dismiss timer or a programmatic toast.dismiss(id). Counterfactual // for the close button: without it there is no [data-slot="sonner-close"] to @@ -1559,3 +1577,44 @@ suite('ui-sonner a11y', () => { root.remove(); }); }); + +suite('ui-dialog naming re-resolves on each open', () => { + suiteSetup(async () => { await import(`${COMPONENTS_DIR}/dialog.ts`); }); + + // The panel keeps its attributes between opens, so the wiring has to clear + // what a previous open wrote before resolving again. Without that, removing + // the title node leaves a stale aria-labelledby pointing at a dead IDREF, + // which resolves to NO name and also suppresses the generic-name floor that + // exists to make an unnamed modal impossible. Both failures are silent. + test('a removed title node does not leave a dead aria-labelledby behind', async () => { + const root = await mount(html` + + +

First title

+

First description.

+
+
+ `); + const host = root.querySelector('ui-dialog'); + host.show(); + await tick(); + const native = root.querySelector('dialog[data-slot="dialog-native"]'); + const firstRef = native.getAttribute('aria-labelledby'); + assert.ok(firstRef, 'the first open names the dialog from its title'); + assert.ok(document.getElementById(firstRef), 'and that IDREF resolves'); + + host.hide(); + await tick(); + root.querySelector('[data-slot="dialog-title"]').remove(); + root.querySelector('[data-slot="dialog-description"]').remove(); + host.show(); + await tick(); + + const ref = native.getAttribute('aria-labelledby'); + assert.equal(ref && document.getElementById(ref), null, 'no stale IDREF survives the re-open'); + assert.equal(native.getAttribute('aria-label'), 'Dialog', 'the generic name floor applies instead'); + const descRef = native.getAttribute('aria-describedby'); + assert.equal(descRef && document.getElementById(descRef), null, 'and no stale description IDREF either'); + root.remove(); + }); +}); diff --git a/packages/ui/test/components/browser/ui-overlay.test.js b/packages/ui/test/components/browser/ui-overlay.test.js index 6733d75e4..5b32bdfb6 100644 --- a/packages/ui/test/components/browser/ui-overlay.test.js +++ b/packages/ui/test/components/browser/ui-overlay.test.js @@ -112,11 +112,22 @@ suite('ui-dialog', () => { `); await tick(); const dialog = root.querySelector('ui-dialog'); - // data-state lives on the inner [role="dialog"] element rendered - // inside the host. - const contentInner = dialog.querySelector('ui-dialog-content [role="dialog"]'); + // data-state lives on the inner content div rendered inside the + // host. Located by its data-slot, NOT by [role="dialog"]: + // the role moved onto the native in #1245, and the two were only + // ever on the same element by coincidence. data-state is a styling hook and + // the role is an accessibility contract, so a locator that conflates them + // breaks whenever either one moves. + const contentInner = dialog.querySelector('ui-dialog-content [data-slot="dialog-content"]'); assert.ok(contentInner, 'inner content element exists in DOM'); assert.equal(contentInner.getAttribute('data-state'), 'closed'); + // Re-pointing the locator above removed this file's only implicit proof + // that the role exists at all, so assert it directly on the element that + // owns it. Otherwise the dialog could lose its role entirely and every + // browser-layer test here would still pass. + const nativeDialog = dialog.querySelector('dialog[data-slot="dialog-native"]'); + assert.equal(nativeDialog.getAttribute('role'), 'dialog', 'native carries role=dialog'); + assert.equal(contentInner.hasAttribute('role'), false, 'and the content panel carries no second dialog role'); assert.equal(getComputedStyle(dialog.querySelector('ui-dialog-content')).display, 'none', 'host hidden when closed'); root.remove(); }); @@ -993,7 +1004,7 @@ suite('ui-alert-dialog', () => { } }); - test('trigger click opens via show(); content has role="alertdialog"', async () => { + test('trigger click opens via show(); native has role="alertdialog"', async () => { const root = await mount(html` @@ -1008,12 +1019,23 @@ suite('ui-alert-dialog', () => { root.querySelector('ui-alert-dialog-trigger [data-slot="alert-dialog-trigger"]').click(); await tick(); assert.ok(ad.hasAttribute('open'), 'host gets [open] attribute'); - const inner = ad.querySelector('ui-alert-dialog-content [role="alertdialog"]'); - assert.ok(inner, 'inner alertdialog rendered'); + // Located by data-slot, NOT by [role="alertdialog"]. The role moved onto + // the native , so a role-based locator here would resolve to the + // same node as `native` below and this would be a weaker duplicate of that + // assertion rather than a check that the content panel rendered. + const inner = ad.querySelector('ui-alert-dialog-content [data-slot="alert-dialog-content"]'); + assert.ok(inner, 'inner content panel rendered'); // showModal() reaches the own-rendered native through the // ref()/createRef() handle, so the native element is actually open. const native = ad.querySelector('dialog[data-slot="alert-dialog-native"]'); assert.ok(native && native.open, 'ref()-driven showModal opened the native '); + // The role lives on the native , which is what this test's name is + // about. Asserted here rather than through a role-based locator, so the + // check is on the element and cannot quietly become a lookup that passes + // because it found some other node. This is the browser layer's only + // assertion of the alertdialog role. + assert.equal(native.getAttribute('role'), 'alertdialog', 'native carries role=alertdialog'); + assert.equal(inner.hasAttribute('role'), false, 'and the content panel carries no second dialog role'); ad.hide(); root.remove(); }); 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..c52e4bed9 --- /dev/null +++ b/packages/ui/test/e2e/a11y-tree.e2e.mjs @@ -0,0 +1,348 @@ +/** + * 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']); + } +} + +// `duration: 0` disables auto-dismiss. Without it a toast defaults to 4000ms +// and the probe races it: this waits 700ms, tags the node, then issues a full +// DOM.getDocument plus getFullAXTree against a large gallery page, and if that +// budget is ever exceeded the toast is gone, the chain resolves to null, and a +// null chain is a hard FAIL by design rather than a skip. The probe never +// asserts dismissal, so removing the timer costs nothing. +await probeToast( + 'Default toast probe', + () => import('/modules/ui/components/sonner.ts').then((m) => m.toast('Default toast probe', { duration: 0 })), + 'default-toast-probe', + false, +); +await probeToast( + 'Error toast probe', + () => import('/modules/ui/components/sonner.ts').then((m) => m.toast.error('Error toast probe', { duration: 0 })), + '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); diff --git a/packages/ui/test/registry-contents.test.js b/packages/ui/test/registry-contents.test.js index cf2c86b09..3a5d5e861 100644 --- a/packages/ui/test/registry-contents.test.js +++ b/packages/ui/test/registry-contents.test.js @@ -189,7 +189,12 @@ test('card : exposes all 7 subpart class helpers (no custom elements)', { skip } test('dialog : delegates to native for modal behavior', { skip }, () => { const src = readSource('dialog'); assert.match(src, /'role',\s*'dialog'|"role",\s*"dialog"|role="dialog"/); - assert.match(src, /aria-modal/); + // No aria-modal assertion: the role moved onto the native (#1245), + // and a showModal()-opened native dialog is already exposed as modal by the + // platform, so the attribute would be redundant on the node that owns the + // role. That the dialog is EXPOSED as modal is asserted where it can + // actually be observed, against the computed accessibility tree, in + // test/e2e/a11y-tree.e2e.mjs. A source regex could never have proven it. // Native dialog is what owns Escape, Tab cycling, and focus restoration. assert.match(src, /showModal/); assert.match(src, /HTMLDialogElement/); @@ -378,3 +383,37 @@ test('the A11y block reaches the agent-facing doc header', { skip }, async () => `these components' A11y blocks sit below an @tag, so extractDocHeader drops them and an agent never sees the obligations: ${dropped.join(', ')}`, ); }); + +// #1245: a non-error toast carries NO role, so it resolves under exactly one +// live root (the polite viewport) instead of two nested ones. +// +// This is a SOURCE-SHAPE assertion, and it is the only layer that can catch the +// regression it guards. The obvious spelling of "no role on an ordinary toast" +// is a nullish hole, `role=${item.type === 'error' ? 'alert' : null}`, which is +// WRONG: the client renderer removes a nullish attribute but the SERVER +// renderer stringifies it, serving `role=""`, and an empty role is not a role, +// so the toast silently falls back to `generic`. The browser test in +// test/components/browser/ui-a11y.test.js runs only the client renderer, so +// `hasAttribute('role') === false` passes identically for the branch and for +// the nullish hole, which means it cannot tell them apart. The SSR layer cannot +// reach it either: `items` is an empty instance signal, so a viewport always +// renders zero toasts server-side and a toast's role never reaches the server +// renderer through markup at all. That leaves the shape of the template as the +// only observable, so assert it here. +test('sonner : branches the toast role rather than emitting a nullish hole', { skip }, () => { + // Strip comments first. The prose in this file explains WHY role="status" + // was removed, so a naive scan of the whole source matches the very + // explanation and the test can never pass. + const code = readSource('sonner') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + assert.match(code, /role="alert"/, 'an error toast still carries role=alert'); + assert.ok( + !/role=\$\{[^}]*\}/.test(code), + 'the toast role is written as a hole, so a falsy arm serves role="" from the server renderer; branch the whole attribute instead', + ); + assert.ok( + !/role="status"/.test(code), + 'a non-error toast still carries role=status, which nests a second live region inside the polite viewport', + ); +}); diff --git a/packages/ui/test/ssr-aria.test.js b/packages/ui/test/ssr-aria.test.js index a31bd0bf2..6e49a679a 100644 --- a/packages/ui/test/ssr-aria.test.js +++ b/packages/ui/test/ssr-aria.test.js @@ -124,3 +124,52 @@ test('ui-toggle-group-item: disabled state reaches the first paint', { skip }, a assert.match(tag, /data-slot="toggle-group-item"/, `served item has no data-slot: ${tag}`); } }); + +// #1245: the dialog-family role moved off the inner content div and onto the +// native , so that exactly ONE dialog-family node is exposed in the +// accessibility tree rather than two nested ones (measured over CDP in +// test/e2e/a11y-tree.e2e.mjs). The role is in render(), which is the only hook +// SSR runs, so the FIRST PAINT is where the move has to be visible. These +// assertions are the SSR half of that contract; the tree assertions are the +// half that proves what the platform actually exposes. +test('ui-dialog-content: the role is on the native at SSR, not the panel', { skip }, async () => { + const out = await ssr( + ['dialog.ts'], + (html) => html`

Edit profile

`, + ); + assert.match( + out, + /]*data-slot="dialog-native"[^>]*role="dialog"|]*role="dialog"[^>]*data-slot="dialog-native"/, + `the native does not carry the role: ${out.slice(0, 400)}`, + ); + const panel = out.match(/]*data-slot="dialog-content"[^>]*>/)?.[0] ?? ''; + assert.ok(panel, 'the content panel rendered'); + assert.ok(!/\brole=/.test(panel), `the inner panel still carries a role, so both are exposed: ${panel}`); + // A showModal()-opened native dialog is exposed as modal by the platform, so + // aria-modal on the node that owns the role would be redundant. The e2e + // asserts the computed `modal` property is still true without it. + assert.ok(!out.includes('aria-modal'), `served a redundant aria-modal: ${out.slice(0, 400)}`); +}); + +test('ui-alert-dialog-content: the alertdialog role is on the native at SSR', { skip }, async () => { + const out = await ssr( + ['alert-dialog.ts'], + (html) => html`

Are you sure?

`, + ); + assert.match( + out, + /]*data-slot="alert-dialog-native"[^>]*role="alertdialog"|]*role="alertdialog"[^>]*data-slot="alert-dialog-native"/, + `the native does not carry the alertdialog role: ${out.slice(0, 400)}`, + ); + const panel = out.match(/]*data-slot="alert-dialog-content"[^>]*>/)?.[0] ?? ''; + assert.ok(panel, 'the content panel rendered'); + assert.ok(!/\brole=/.test(panel), `the inner panel still carries a role, so both are exposed: ${panel}`); + assert.ok(!out.includes('aria-modal'), `served a redundant aria-modal: ${out.slice(0, 400)}`); +}); + +// The sonner toast role is deliberately NOT asserted here. `items` is an empty +// instance signal, so a viewport always renders zero toasts server-side and a +// toast's role never reaches the server renderer through markup at all. The +// branch in sonner.ts is still the correct shape (a nullish hole is the +// documented footgun this file exists for), but its observable contract lives +// in the browser suite and in the accessibility-tree e2e. diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs index b29083d4b..11dd08f13 100644 --- a/test/e2e/e2e.test.mjs +++ b/test/e2e/e2e.test.mjs @@ -1176,6 +1176,61 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 assert.ok(result.hasButtonClassOutput, 'buttonClass() Tailwind output should be present'); }); + test('/ui-demo: an opened dialog exposes ONE dialog node, and it is named', async () => { + // The role sits on the native , which already has an implicit + // dialog role, so a second one on the content panel would expose two nested + // dialog-family nodes. Declaring the role also obliges the element to carry + // a NAME: a modal that announces itself as a dialog with nothing to read is + // worse than one a reader treats as ordinary content. Naming is wired at + // showModal() time, so it can only be observed with the dialog open. + await page.goto(baseUrl + '/ui-demo', { waitUntil: 'domcontentloaded', timeout: 10000 }); + await sleep(1500); + + const opened = await page.evaluate(() => { + const btn = [...document.querySelectorAll('button')] + .find((b) => /open dialog/i.test(b.textContent || '')); + if (!btn) return false; + btn.click(); + return true; + }); + assert.ok(opened, 'the "Open dialog" trigger should be present'); + await sleep(600); + + const seen = await page.evaluate(() => { + const host = document.querySelector('ui-dialog'); + const native = host?.querySelector('dialog[data-slot="dialog-native"]'); + const panel = host?.querySelector('[data-slot="dialog-content"]'); + if (!native || !panel) return null; + const labelledBy = native.getAttribute('aria-labelledby'); + const describedBy = native.getAttribute('aria-describedby'); + const nameTarget = labelledBy ? document.getElementById(labelledBy) : null; + const descTarget = describedBy ? document.getElementById(describedBy) : null; + return { + open: native.open, + nativeRole: native.getAttribute('role'), + panelHasRole: panel.hasAttribute('role'), + // Whether the IDREF actually resolves, which a dead one would not. + nameResolves: !!nameTarget, + descResolves: !!descTarget, + name: (nameTarget?.textContent || native.getAttribute('aria-label') || '').trim(), + desc: (descTarget?.textContent || '').trim(), + }; + }); + + assert.ok(seen, 'the native and its content panel should both be found'); + assert.ok(seen.open, 'the dialog should be open'); + assert.equal(seen.nativeRole, 'dialog', 'the native carries role="dialog"'); + assert.equal(seen.panelHasRole, false, 'the content panel carries no second dialog role'); + // Assert the RESOLVED title, not merely that some name exists. A + // non-empty check passes on the generic "Dialog" floor alone, so it would + // stay green with the entire title lookup deleted and could never observe + // that the wiring found this demo's . + assert.ok(seen.nameResolves, 'aria-labelledby must point at a live element, not a dead IDREF'); + assert.equal(seen.name, 'Edit profile', 'the name comes from the demo title node'); + assert.ok(seen.descResolves, 'aria-describedby must point at a live element'); + assert.match(seen.desc, /Make changes to your profile/, 'the description comes from the demo description node'); + }); + test('/ui-demo: clicking a button does not crash the page', async () => { const errors = []; page.on('pageerror', (e) => errors.push(e.message)); diff --git a/test/examples/blog/smoke/blog-smoke.test.js b/test/examples/blog/smoke/blog-smoke.test.js index 28824d842..211a2d06e 100644 --- a/test/examples/blog/smoke/blog-smoke.test.js +++ b/test/examples/blog/smoke/blog-smoke.test.js @@ -174,6 +174,23 @@ describe('Blog smoke (Tier-1/Tier-2 migration)', { skip: skip && 'blog or its DB assert.match(html, new RegExp(`<${tag}\\b`), `expected <${tag}> on /ui-demo`); } + // The dialog role belongs on the native , and NOWHERE else in the + // rendered output. The native element has an implicit dialog role of its + // own, so a second one on the inner content div exposes two nested + // dialog-family nodes to a screen reader. This is the first paint, which is + // where render() puts the role, so it is observable here. + assert.match( + html, + /]*data-slot="dialog-native"[^>]*role="dialog"|]*role="dialog"[^>]*data-slot="dialog-native"/, + 'the native should carry role="dialog"', + ); + const contentDiv = html.match(/]*data-slot="dialog-content"[^>]*>/)?.[0] ?? ''; + assert.ok(contentDiv, 'the dialog content panel should render'); + assert.doesNotMatch(contentDiv, /\brole=/, `the content panel must carry no second dialog role: ${contentDiv}`); + // aria-modal is redundant on a showModal()-opened native dialog, which the + // platform already exposes as modal. + assert.doesNotMatch(html, /aria-modal/, '/ui-demo should serve no aria-modal'); + // No stale Tier-1 tags. for (const tag of ['ui-button', 'ui-card', 'ui-card-header', 'ui-input', 'ui-label', 'ui-alert', 'ui-badge']) { assert.doesNotMatch(html, new RegExp(`<${tag}\\b`), `/ui-demo should not render <${tag}>`);