Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions blog/accessible-web-components-by-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ The rendered output carries `role="tablist"`, `role="tab"` with `aria-selected`

`<ui-tabs>` 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 `<button role="tab">` with `aria-selected` and `aria-controls` pointing at its panel, and each panel gets `role="tabpanel"` with `aria-labelledby` pointing back at its trigger. Focus is roving (the active trigger has `tabindex="0"`, the rest `-1`) and Arrow keys, Home, and End move between tabs. An inactive panel is marked `hidden` and `inert`, so it drops out of the tab order and the accessibility tree entirely.

`<ui-dialog>` is a thin decorator over the native `<dialog>` element's `showModal()`. That means the focus trap, Escape-to-close, the backdrop, and background-inert all come from the platform rather than from hand-rolled JS that tends to have edge-case bugs. On open it names itself from your title and description via `aria-labelledby` and `aria-describedby`, marks the panel `role="dialog"` with `aria-modal="true"`, and gives the auto-injected close button an `aria-label="Close"`.
`<ui-dialog>` is a thin decorator over the native `<dialog>` element's `showModal()`. That means the focus trap, Escape-to-close, the backdrop, and background-inert all come from the platform rather than from hand-rolled JS that tends to have edge-case bugs. On open it names itself from your title and description via `aria-labelledby` and `aria-describedby`, puts `role="dialog"` on the native `<dialog>` itself so exactly one dialog node is exposed rather than a role nested inside the element's implicit one, and gives the auto-injected close button an `aria-label="Close"`. There is no `aria-modal`, because a dialog opened with `showModal()` is already exposed as modal by the platform.

The rest follow the same discipline. The dropdown menu declares `aria-haspopup="menu"`, uses `role="menu"` and `role="menuitem"` with roving focus and `aria-disabled`. The tooltip wires `aria-describedby` from the trigger to the tip text. The toaster (`<ui-sonner>`) is a live region, so new toasts get announced (`role="alert"` for errors, `role="status"` otherwise). Across the set, interactive elements carry `focus-visible` ring styles so keyboard users can see where focus is.
The rest follow the same discipline. The dropdown menu declares `aria-haspopup="menu"`, uses `role="menu"` and `role="menuitem"` with roving focus and `aria-disabled`. The tooltip wires `aria-describedby` from the trigger to the tip text. The toaster (`<ui-sonner>`) is a polite live region, so new toasts get announced, and an error toast additionally carries `role="alert"`. An ordinary toast carries no role of its own, so it resolves under exactly one live region rather than a second one nested inside the viewport. Across the set, interactive elements carry `focus-visible` ring styles so keyboard users can see where focus is.


# The part aimed at AI agents
Expand Down
85 changes: 82 additions & 3 deletions examples/blog/components/ui/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,86 @@ import { WebComponent, html, unsafeHTML, prop } from '@webjsdev/core';
import { ref, createRef } from '@webjsdev/core/directives';
import { buttonClass } from './button.ts';

// Gives an id to an element that lacks one, so it can be referenced.
function ensureId(el: HTMLElement, prefix: string): string {
if (!el.id) el.id = `${prefix}-${Math.random().toString(36).slice(2, 9)}`;
return el.id;
}

// Names and describes the dialog panel from its title / description nodes.
// A dialog only ever appears via showModal(), so resolving this at open time
// is correct and avoids any SSR id-stability concern.
//
// The panel here is the native <dialog>, which is the element carrying
// role="dialog", so the name has to land on it rather than on the inner
// content div. Declaring a role WITHOUT a name is the failure this exists to
// prevent: a modal that reports itself as a dialog and then has nothing to
// announce is worse than one the reader treats as ordinary content.
//
// The title lookup covers this app's <ui-dialog-title> and <ui-dialog-description>
// markup as well as the data-slot and bare-heading forms, since those tags are
// not registered here and so leave no heading behind.
function wireDialogLabels(host: Element): void {
const panel = host.querySelector('dialog[data-slot="dialog-native"]');
if (!panel) return;

const authoredLabelledBy = host.getAttribute('aria-labelledby');
if (authoredLabelledBy) {
panel.setAttribute('aria-labelledby', authoredLabelledBy);
wireDialogDescription(host, panel);
return;
}
const authoredLabel = host.getAttribute('aria-label');
if (authoredLabel) {
panel.setAttribute('aria-label', authoredLabel);
panel.removeAttribute('aria-labelledby');
wireDialogDescription(host, panel);
return;
}

// Clear what a PREVIOUS open wrote before resolving again. The panel keeps
// its attributes between opens, so a guard that skips when the attribute is
// already there can never re-resolve: remove the title node and re-open, and
// the stale aria-labelledby survives, points at a dead IDREF, resolves to no
// name, and suppresses the floor below that exists to prevent exactly that.
panel.removeAttribute('aria-labelledby');

const title =
host.querySelector('[data-slot="dialog-title"]') ??
host.querySelector('ui-dialog-title') ??
host.querySelector('h1, h2, h3');
if (title) {
panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-dialog-title'));
}
wireDialogDescription(host, panel);

// APG: a modal MUST have an accessible name. Everything above supplies one
// only when the author gave it a title node or named it directly, so this is
// the floor that makes an unnamed modal impossible. Not a substitute for a
// real title.
if (!panel.hasAttribute('aria-labelledby') && !panel.hasAttribute('aria-label')) {
panel.setAttribute('aria-label', 'Dialog');
}
}

function wireDialogDescription(host: Element, panel: Element): void {
const authored = host.getAttribute('aria-describedby');
if (authored) {
panel.setAttribute('aria-describedby', authored);
return;
}
// Same re-resolve rule as the name above: clear the previous open's value
// first, or a removed description node leaves a dead IDREF behind.
panel.removeAttribute('aria-describedby');
const desc =
host.querySelector('[data-slot="dialog-description"]') ??
host.querySelector('ui-dialog-description') ??
host.querySelector('p');
if (desc) {
panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-dialog-desc'));
}
}

// --------------------------------------------------------------------------
// Class helpers for subparts.
// --------------------------------------------------------------------------
Expand Down Expand Up @@ -398,6 +478,7 @@ export class UiDialogContent extends WebComponent({
}

showModal(): void {
wireDialogLabels(this);
const native = this.#dialog.value;
if (native && !native.open) native.showModal();
}
Expand All @@ -412,15 +493,13 @@ export class UiDialogContent extends WebComponent({
const parentOpen = !!this._parent()?.open;
return html`<dialog
data-slot="dialog-native"
role="dialog"
class=${NATIVE_DIALOG_CLASS}
${ref(this.#dialog)}
@close=${this._onNativeClose}
@click=${this._onNativeBackdropClick}
><div
data-slot="dialog-content"
role="dialog"
aria-modal="true"
tabindex="-1"
data-state=${parentOpen ? 'open' : 'closed'}
class=${dialogContentClass()}
>
Expand Down
15 changes: 9 additions & 6 deletions packages/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,13 @@ tracked source, the standard shadcn "you own it" pattern.
| 1b | `collapsible` | `collapsibleClass`, `collapsibleTriggerClass`, `collapsibleContentClass`. Compose with `<details>` + `<summary>`. |
| 1b | `progress` | `progressClass()`, apply to native `<progress value max>`. Browser draws the bar via `::-webkit-progress-value` and `::-moz-progress-bar`. Omit `value` for the indeterminate / pulse state. |
| 2 | `toggle-group` | `<ui-toggle-group type value variant size>` + `<ui-toggle-group-item value disabled>`. 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` | `<ui-dialog>` + `<ui-dialog-trigger>` / `<ui-dialog-content>` / `<ui-dialog-close>`. Built on native `<dialog>.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. `<ui-alert-dialog-action>` / `<ui-alert-dialog-cancel>`. Wires `aria-labelledby` / `aria-describedby` to its `alert-dialog-title` / `alert-dialog-description` the same way. |
| 2 | `dialog` | `<ui-dialog>` + `<ui-dialog-trigger>` / `<ui-dialog-content>` / `<ui-dialog-close>`. Built on native `<dialog>.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 `<dialog>`, 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 `<dialog>`. Native Escape close is cancelled via the `cancel` event; no backdrop-click dismissal. `<ui-alert-dialog-action>` / `<ui-alert-dialog-cancel>`. Wires `aria-labelledby` / `aria-describedby` to its `alert-dialog-title` / `alert-dialog-description` the same way. |
| 2 | `tooltip` | `<ui-tooltip delay-duration>`, 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` | `<ui-hover-card open-delay close-delay>`, 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` | `<ui-tabs value orientation>` + 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` | `<ui-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` | `<ui-sonner position>` + `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` | `<ui-sonner position>` + `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

Expand All @@ -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 `<dialog>` (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.

Expand Down
3 changes: 2 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 19 additions & 7 deletions packages/ui/packages/registry/components/alert-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dialog>`, 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.
*
Expand Down Expand Up @@ -417,18 +422,23 @@ export class UiAlertDialogContent extends WebComponent({
panel.setAttribute('aria-describedby', authored);
return;
}
// Clear the previous open's value before resolving again. The panel keeps
// its attributes between opens, so a guard that skips when the attribute is
// already present can never re-resolve, and a removed description node
// leaves a stale aria-describedby pointing at a dead IDREF.
panel.removeAttribute('aria-describedby');
const desc =
this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
if (desc && !panel.hasAttribute('aria-describedby')) {
if (desc) {
panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
}
}

_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 <ui-alert-dialog-content> is where they
// naturally write it, but role="alertdialog" lives on the inner panel.
// naturally write it, but role="alertdialog" lives on the native <dialog>.
//
// Each authored-name branch RETURNS rather than falling through to the
// title wiring. Falling through would set aria-labelledby from the title
Expand All @@ -449,9 +459,13 @@ export class UiAlertDialogContent extends WebComponent({
this._wireDescription(panel);
return;
}
// Same re-resolve rule: clear what a previous open wrote, or a removed
// title node leaves a stale aria-labelledby that points at a dead IDREF AND
// suppresses the floor below, which exists to prevent exactly that state.
panel.removeAttribute('aria-labelledby');
const title =
this.querySelector('[data-slot="alert-dialog-title"]') ?? this.querySelector('h1, h2, h3');
if (title && !panel.hasAttribute('aria-labelledby')) {
if (title) {
panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-alert-title'));
}
this._wireDescription(panel);
Expand All @@ -472,15 +486,13 @@ export class UiAlertDialogContent extends WebComponent({
const parentOpen = !!this._parent()?.open;
return html`<dialog
data-slot="alert-dialog-native"
role="alertdialog"
class=${NATIVE_DIALOG_CLASS}
${ref(this.#dialog)}
@cancel=${this._onNativeCancel}
@close=${this._onNativeClose}
><div
data-slot="alert-dialog-content"
role="alertdialog"
aria-modal="true"
tabindex="-1"
data-size=${this.size}
data-state=${parentOpen ? 'open' : 'closed'}
class=${alertDialogContentClass()}
Expand Down
Loading
Loading