You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The @webjsdev/ui registry does not deliver 100% accessibility out of the box. An audit of all 37 registry components (packages/ui/packages/registry/components/*.ts) found two classes of problem.
1. Tier-2 custom-element components ship concrete WAI-ARIA gaps. These are bugs, not author responsibility, because the element owns the markup.
tabs.ts: triggers lack aria-controls pointing at their panel, panels lack aria-labelledby back to the trigger, the root lacks aria-orientation, and inactive panels stay in the tab order (only visually hidden via this.hidden).
toggle-group.ts: all items are tabIndex=0 (no roving tabindex) and there is no Arrow-key navigation. The APG button-group pattern requires both.
dropdown-menu.ts: role="menu" has no aria-orientation, disabled items use data-disabled only (no aria-disabled), and the menu has no aria-label or aria-labelledby tying it to its trigger.
dialog.ts and alert-dialog.ts: the dialog container is never wired to its title or description via aria-labelledby and aria-describedby, so assistive tech does not announce the dialog name on open.
tooltip.ts: the trigger has no aria-describedby link to the tooltip content and no aria-haspopup, and the content is not aria-hidden when closed.
hover-card.ts: the trigger has no aria-haspopup or aria-expanded.
sonner.ts: the toast container has no aria-live region, so dynamically inserted toasts may not be announced.
2. Tier-1 class-helper components shift required ARIA to the caller with no enforcement or explicit guidance.pagination, breadcrumb, alert, separator, skeleton, table, avatar, and the icon-size button return only Tailwind class strings. Correct output requires the caller to add <nav aria-label>, aria-current="page", scope="col", alt, role="none" or aria-hidden, aria-busy, and aria-label (icon buttons). The usage comments show correct markup in some files but do not state the requirement as mandatory.
Why this matters here specifically. webjs end users are mostly AI agents. An agent copying a Tier-1 helper without the surrounding semantic markup, or wiring up the Tier-2 components as-is, ships inaccessible code. The registry must both fix the Tier-2 element gaps AND carry per-component, agent-readable a11y requirements precise enough that an agent produces 100% accessible markup by default.
Design / approach
Two workstreams.
A. Fix the Tier-2 custom elements so they are accessible with zero author effort. Generate stable ids per instance and wire aria-controls, aria-labelledby, and aria-describedby. Add aria-orientation. Implement roving tabindex plus Arrow, Home, and End navigation in toggle-group. Add aria-disabled to disabled menu items. Make inactive tab panels inert (or remove them from the tab order). Add an aria-live region container to sonner. Add aria-haspopup, aria-expanded, and aria-describedby to tooltip and hover-card triggers. Where a title or description slot may be absent, fall back gracefully (only set the reference when the referenced node exists).
B. Make accessibility legible to AI agents. For every component, add an explicit, machine-followable a11y contract.
A structured A11y: block in each registry component's top JSDoc comment listing the REQUIRED attributes the caller must supply (Tier-1) or confirming what the element provides (Tier-2).
Update packages/ui/AGENTS.md (the component table plus a new "Accessibility contract per component" section) and the docs-site component pages (packages/ui/packages/website/app/docs/components/[name]/) so the same contract appears everywhere an agent reads.
Consider a webjsui check or test assertion that flags icon-size buttons without an accessible name and Tier-1 examples missing required ARIA (optional, scope permitting).
Prior art in-repo: #268 added an opt-in a11y assertion to the test layer (ssrFixture()), reuse it for the SSR-level checks. #252 already added aria-busy semantics for frame swaps.
Implementation notes (for the implementing agent)
Single source of truth. Edit ONLY packages/ui/packages/registry/components/*.ts. The mirror at packages/ui/packages/website/components/ui/ is GITIGNORED and auto-regenerated by scripts/copy-registry.js via webjs.dev.before and webjs.start.before (Unify webjs dev/start/db with npm-script behavior via a declarative tasks config #550). Do not hand-edit it.
dropdown-menu.ts: content role="menu" (~L328), item render and role="menuitem" (~L359), disabled flag data-disabled (~L374), open and focus logic (~L200 to L204).
dialog.ts (~L291) and alert-dialog.ts (~L256): wire the references to the title and description nodes in the slotted content.
sonner.ts: container render (~L191 to L194). Add the live region to the container, keep the per-toast role (~L201).
Where to edit (Tier-1 guidance): top JSDoc of pagination.ts, breadcrumb.ts, alert.ts, separator.ts, skeleton.ts, table.ts, avatar.ts, button.ts (icon sizes ~L53 to L56), badge.ts, progress.ts.
Landmines:
These components SSR (light DOM by default). Any id generation must be deterministic and stable across SSR and hydration or aria-labelledby will dangle. Do not use random ids at render time without seeding. Prefer slot-derived ids or a per-instance counter set in the constructor. A browser-only global in render() or the constructor throws at SSR (AGENTS.md invariant Corrected backtick errors #1, no-browser-globals-in-render).
Only set aria-labelledby or aria-describedby when the target exists, else you create a broken reference (worse than none).
Inactive tab panels: prefer inert over a tabindex-only fix, and ensure it does not break the existing this.hidden visual toggle.
Tests (every layer the change touches):
Browser tests live in packages/ui/test/components/browser/ (ui-overlay.test.js, ui-stateful.test.js). Add assertions for roving focus, Arrow-key nav, aria wiring, and live-region announcement. Run via npm run test:browser.
Add a counterfactual that fails when the aria wiring is reverted.
Docs surfaces to sync (definition of done, AGENTS.md item 2 plus webjs-doc-sync): packages/ui/AGENTS.md, packages/ui/README.md, the docs-site component pages under packages/ui/packages/website/app/docs/components/, and the per-component JSDoc. Invoke the webjs-doc-sync skill.
tabs: triggers have aria-controls, panels have aria-labelledby, root has aria-orientation, inactive panels are out of the tab order (inert).
toggle-group: roving tabindex (one tabbable item) plus Arrow, Home, and End navigation, with aria-pressed staying correct.
dropdown-menu: aria-orientation on the menu, aria-disabled on disabled items, menu labeled by its trigger.
dialog and alert-dialog: container wired to title via aria-labelledby and description via aria-describedby when present.
tooltip and hover-card: trigger carries aria-describedby and aria-haspopup (tooltip) and aria-haspopup and aria-expanded (hover-card), with tooltip content aria-hidden when closed.
sonner: the toast container is an aria-live region and new toasts are announced.
Every Tier-1 helper carries an explicit, agent-readable A11y: contract in JSDoc stating the REQUIRED caller-supplied attributes.
packages/ui/AGENTS.md, README, and the docs-site component pages document the per-component a11y contract.
Browser and SSR tests cover the new behaviour at every layer, with a counterfactual that fires on revert.
Problem
The
@webjsdev/uiregistry does not deliver 100% accessibility out of the box. An audit of all 37 registry components (packages/ui/packages/registry/components/*.ts) found two classes of problem.1. Tier-2 custom-element components ship concrete WAI-ARIA gaps. These are bugs, not author responsibility, because the element owns the markup.
tabs.ts: triggers lackaria-controlspointing at their panel, panels lackaria-labelledbyback to the trigger, the root lacksaria-orientation, and inactive panels stay in the tab order (only visually hidden viathis.hidden).toggle-group.ts: all items aretabIndex=0(no roving tabindex) and there is no Arrow-key navigation. The APG button-group pattern requires both.dropdown-menu.ts:role="menu"has noaria-orientation, disabled items usedata-disabledonly (noaria-disabled), and the menu has noaria-labeloraria-labelledbytying it to its trigger.dialog.tsandalert-dialog.ts: the dialog container is never wired to its title or description viaaria-labelledbyandaria-describedby, so assistive tech does not announce the dialog name on open.tooltip.ts: the trigger has noaria-describedbylink to the tooltip content and noaria-haspopup, and the content is notaria-hiddenwhen closed.hover-card.ts: the trigger has noaria-haspopuporaria-expanded.sonner.ts: the toast container has noaria-liveregion, so dynamically inserted toasts may not be announced.2. Tier-1 class-helper components shift required ARIA to the caller with no enforcement or explicit guidance.
pagination,breadcrumb,alert,separator,skeleton,table,avatar, and the icon-sizebuttonreturn only Tailwind class strings. Correct output requires the caller to add<nav aria-label>,aria-current="page",scope="col",alt,role="none"oraria-hidden,aria-busy, andaria-label(icon buttons). The usage comments show correct markup in some files but do not state the requirement as mandatory.Why this matters here specifically. webjs end users are mostly AI agents. An agent copying a Tier-1 helper without the surrounding semantic markup, or wiring up the Tier-2 components as-is, ships inaccessible code. The registry must both fix the Tier-2 element gaps AND carry per-component, agent-readable a11y requirements precise enough that an agent produces 100% accessible markup by default.
Design / approach
Two workstreams.
A. Fix the Tier-2 custom elements so they are accessible with zero author effort. Generate stable ids per instance and wire
aria-controls,aria-labelledby, andaria-describedby. Addaria-orientation. Implement roving tabindex plus Arrow, Home, and End navigation in toggle-group. Addaria-disabledto disabled menu items. Make inactive tab panelsinert(or remove them from the tab order). Add anaria-liveregion container to sonner. Addaria-haspopup,aria-expanded, andaria-describedbyto tooltip and hover-card triggers. Where a title or description slot may be absent, fall back gracefully (only set the reference when the referenced node exists).B. Make accessibility legible to AI agents. For every component, add an explicit, machine-followable a11y contract.
A11y:block in each registry component's top JSDoc comment listing the REQUIRED attributes the caller must supply (Tier-1) or confirming what the element provides (Tier-2).packages/ui/AGENTS.md(the component table plus a new "Accessibility contract per component" section) and the docs-site component pages (packages/ui/packages/website/app/docs/components/[name]/) so the same contract appears everywhere an agent reads.webjsuicheck or test assertion that flags icon-size buttons without an accessible name and Tier-1 examples missing required ARIA (optional, scope permitting).Prior art in-repo: #268 added an opt-in a11y assertion to the test layer (
ssrFixture()), reuse it for the SSR-level checks. #252 already addedaria-busysemantics for frame swaps.Implementation notes (for the implementing agent)
packages/ui/packages/registry/components/*.ts. The mirror atpackages/ui/packages/website/components/ui/is GITIGNORED and auto-regenerated byscripts/copy-registry.jsviawebjs.dev.beforeandwebjs.start.before(Unify webjs dev/start/db with npm-script behavior via a declarative tasks config #550). Do not hand-edit it.tabs.ts:UiTabsList.render()(~L150), trigger render (~L186), panel render (~L251 to L252).toggle-group.ts: itemtabIndex(~L184), keyboard handler (~L217 to L221),aria-pressedsync (~L205).dropdown-menu.ts: contentrole="menu"(~L328), item render androle="menuitem"(~L359), disabled flagdata-disabled(~L374), open and focus logic (~L200 to L204).dialog.ts(~L291) andalert-dialog.ts(~L256): wire the references to the title and description nodes in the slotted content.tooltip.ts(trigger ~L166, content ~L192) andhover-card.ts(trigger ~L140, content ~L165).sonner.ts: container render (~L191 to L194). Add the live region to the container, keep the per-toastrole(~L201).pagination.ts,breadcrumb.ts,alert.ts,separator.ts,skeleton.ts,table.ts,avatar.ts,button.ts(icon sizes ~L53 to L56),badge.ts,progress.ts.aria-labelledbywill dangle. Do not use random ids at render time without seeding. Prefer slot-derived ids or a per-instance counter set in the constructor. A browser-only global inrender()or the constructor throws at SSR (AGENTS.md invariant Corrected backtick errors #1,no-browser-globals-in-render).htmltemplate bodies (invariant feat(server): replace esbuild TS stripping with Node 24+ strip-types #9). No em-dash or pause-punctuation in new prose (invariant release: bump core/server/cli versions, honest engines fields #11), which affects the JSDoc a11y blocks you add.aria-labelledbyoraria-describedbywhen the target exists, else you create a broken reference (worse than none).inertover a tabindex-only fix, and ensure it does not break the existingthis.hiddenvisual toggle.packages/ui/test/components/browser/(ui-overlay.test.js,ui-stateful.test.js). Add assertions for roving focus, Arrow-key nav, aria wiring, and live-region announcement. Run vianpm run test:browser.ssrFixture()for server-rendered attribute checks (unit layer underpackages/ui/test/).packages/ui/AGENTS.md,packages/ui/README.md, the docs-site component pages underpackages/ui/packages/website/app/docs/components/, and the per-component JSDoc. Invoke thewebjs-doc-syncskill.chore/release-*branch).Acceptance criteria
aria-controls, panels havearia-labelledby, root hasaria-orientation, inactive panels are out of the tab order (inert).aria-pressedstaying correct.aria-orientationon the menu,aria-disabledon disabled items, menu labeled by its trigger.aria-labelledbyand description viaaria-describedbywhen present.aria-describedbyandaria-haspopup(tooltip) andaria-haspopupandaria-expanded(hover-card), with tooltip contentaria-hiddenwhen closed.aria-liveregion and new toasts are announced.A11y:contract in JSDoc stating the REQUIRED caller-supplied attributes.packages/ui/AGENTS.md, README, and the docs-site component pages document the per-component a11y contract.webjs checkclean, no new invariant violations.