Skip to content

UI: 100% accessibility out of the box + AI-agent-facing a11y contracts #655

@vivek7405

Description

@vivek7405

Problem

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.
  • Where to edit (Tier-2 elements):
    • tabs.ts: UiTabsList.render() (~L150), trigger render (~L186), panel render (~L251 to L252).
    • toggle-group.ts: item tabIndex (~L184), keyboard handler (~L217 to L221), aria-pressed sync (~L205).
    • 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.
    • tooltip.ts (trigger ~L166, content ~L192) and hover-card.ts (trigger ~L140, content ~L165).
    • 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).
    • No backticks inside html template 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.
    • 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.
    • Reuse the Add ssrFixture() and an opt-in a11y assertion to the test layer #268 SSR a11y assertion and ssrFixture() for server-rendered attribute checks (unit layer under packages/ui/test/).
    • 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.
  • Invariants to respect: AGENTS.md invariants Corrected backtick errors #1, feat(server): replace esbuild TS stripping with Node 24+ strip-types #9, release: bump core/server/cli versions, honest engines fields #11, and the published-package release rule (no version bump off a non-chore/release-* branch).

Acceptance criteria

  • 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.
  • webjs check clean, no new invariant violations.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions