Skip to content

feat(a11y): ElementInternals accessibility layer + WCAG 2.2 AA remediation across all components#1632

Open
plasticmind wants to merge 9 commits into
developfrom
fix/accessibility-remediation
Open

feat(a11y): ElementInternals accessibility layer + WCAG 2.2 AA remediation across all components#1632
plasticmind wants to merge 9 commits into
developfrom
fix/accessibility-remediation

Conversation

@plasticmind

Copy link
Copy Markdown
Contributor

Summary

Integrates ElementInternals as the accessibility-semantics layer across all ~39 nys-* components, backed by a new shared @nysds/internals workspace module, and remediates a set of concrete WCAG 2.2 AA gaps surfaced by a full component audit. No breaking changes to public component API.

The audit reframed the original premise: form-association (formAssociated + attachInternals for setFormValue/setValidity) already existed on the form controls. The real gap was that ElementInternals was not used as the accessibility layer — default roles and ARIA state were hand-wired per component (or missing), there was no shared infrastructure, and a few real WCAG gaps existed (e.g. nys-textinput building a synthetic duplicated aria-label instead of associating its visible <nys-label>).

What's in here

New shared layer — packages/internals (@nysds/internals)

A private, unpublished workspace module (consumed as a devDependency, inlined at build) providing a layered, tree-shakeable Lit mixin architecture:

  • NysElement / IdentifiedMixin — auto id generation only (presentational components that don't reflect a host role)
  • NysReflectsAriaElement / ReflectsAriaMixin — adds host role + ARIA-state reflection via ElementInternals (currently nys-dividerrole="separator")
  • NysFormControlElement / FormControlMixin — adds form association, validity helpers (setFormValue, setValidityFromState, clearValidity), and host aria-invalid

Plus à-la-carte ARIA-association helpers (associateControl/associateHost with element-reference support + IDREF fallback) and generateId (preserves the legacy id format). 25 unit tests across 6 browsers.

Component migrations

  • 10 form controlsNysFormControlElement: removed per-component _internals/formAssociated/id-counter boilerplate; route value/validity through the shared helpers.
  • ~26 presentational componentsNysElement (id-only); nys-dividerNysReflectsAriaElement.

The hybrid accessibility approach (intentional, per component)

  • ElementInternals AOM for form value/validity, host aria-invalid, and host roles where the host is the semantic element (nys-divider).
  • IDREF / template ARIA for accessible names on inner native controls (aria-labelledby → the visible <nys-label>, replacing synthetic aria-label) and for inner/landmark roles (alert live region, breadcrumbs/pagination nav, tabs). This is deliberate: ElementInternals reflects on the host's a11y node, not the inner <input> the AT interacts with under delegatesFocus.

WCAG fixes landed

nys-textinput visible-label association (1.3.1 / 2.5.3); nys-datepicker error text associated via aria-errormessage + aria-invalid; nys-breadcrumbs / nys-pagination aria-current + nav landmark; nys-dropdownmenu menu accessible name; nys-accordion heading/region; nys-avatar name + decorative icon; nys-backtotop reduced-motion handling; and others.

Reliability + tooling

  • Form controls now self-register their internal <nys-label>/<nys-errormessage> (with honest sideEffects) so the accessible-name association is reliable.
  • plop templates scaffold new components onto the correct base + dep + sideEffects + tsconfig reference.
  • MCP form-validation guide corrected (aria-errormessage/aria-labelledby, not aria-describedby).
  • Build-order, tsconfig.build.json, and CEM config wired for the new package (excluded from the manifest — it declares no elements).

Notes for reviewers

  • No public reactive props live in the mixins — they'd vanish from the Custom Elements Manifest (and thus React wrappers/MCP/Storybook controls). All @property declarations stay on the leaf components; the CEM diff confirms public attributes/props/events are unchanged.
  • Concrete base classes are exported as export const X = /*@__PURE__*/ Mixin(LitElement) (not class X extends Mixin(...)) so unused mixins tree-shake out of each bundle.

Verification

  • 37/37 packages build cleanly; ESLint clean.
  • 1011 tests pass across all 6 browser targets; coverage 91.4% statements / 86.9% functions / 80.5% branches (above the 80% gate).
  • Branch is synced to the latest develop (merge resolved: kept the aria-errormessage remediation over the older aria-describedby pattern, combined nys-backtotop SSR-guard + reduced-motion, aligned versions to 1.19.1, deduped sideEffects keys, regenerated the manifest + React wrappers).

The full PRD lives in-repo at docs/accessibility-remediation-PRD.md.

Out of scope (tracked separately)

  • nys-label/nys-errormessage standalone-vs-internal architecture refactor.
  • packages/ reorganization into components/foundations/frameworks/tools.

plasticmind and others added 6 commits June 13, 2026 20:58
…to ElementInternals AOM

Introduce packages/internals — a private, tree-shake-safe shared module of Lit
mixins (FormControlMixin/NysFormControlElement, ReflectsAriaMixin/
NysReflectsAriaElement) centralizing ElementInternals form association, validity,
default ARIA semantics, and id generation, plus a-la-carte associateControl/
associateHost/supportsElementRefs helpers (ARIA element-reference reflection with
ID-based fallback).

Migrate all form controls (button, checkbox+group, combobox, datepicker,
fileinput, radiobutton+group, select, textarea, textinput, toggle) to extend
NysFormControlElement: remove duplicated _internals/formAssociated/id-gen
boilerplate; route value/validity through shared helpers; associate the native
control accessible name to the visible nys-label via aria-labelledby instead of a
synthetic aria-label (WCAG 1.3.1/2.5.3); self-register internal nys-label and
nys-errormessage. nys-button drops the redundant role="button"; nys-datepicker
associates its error message.

Wiring: tsconfig.build.json references, build-order.js, CEM exclude for internals,
sideEffects:true on form-control packages. Regenerate CEM + React wrappers.

Full suite: 886 tests passing across 6 browsers; coverage 91.5% statements /
80% branches. Adds docs/accessibility-remediation-PRD.md.
…d NysElement; fix WCAG gaps

Add a minimal id-only base (NysElement / IdentifiedMixin) layered below
ReflectsAriaMixin, and define all concrete base classes (NysElement,
NysReflectsAriaElement, NysFormControlElement) as pure /*@__PURE__*/ const
assignments so unused mixin chains tree-shake out of every consuming bundle
(previously the whole internals module was inlined into each component, bloating
bundles and tanking coverage).

Migrate presentational components to the shared base: id-only components (alert,
accordion(+item), avatar, backtotop, badge, breadcrumbs, dropdownmenu(+item),
globalfooter, globalheader, icon, modal, pagination, skipnav, stepper(+step),
tab(+group,+panel), table, tooltip, unavfooter, unavheader, video,
radiobutton-item) extend NysElement; nys-divider (host role=separator) extends
NysReflectsAriaElement. Each drops its local id counter for the shared id gen.

WCAG fixes from the per-component audit: breadcrumbs current item now has
aria-current="page"; pagination wrapped in a named nav landmark with
aria-current on the current page; dropdownmenu menu container gets an accessible
name; accordion uses a heading wrapper (role=heading/aria-level) with a labelled
region; avatar gives interactive avatars an accessible name and marks the
decorative icon aria-hidden; and other per-component name/role/keyboard fixes.

Wiring: @nysds/internals devDep, sideEffects:true, and tsconfig references on
migrated packages. Regenerate CEM + React wrappers.

Full suite: 1000 tests passing across 6 browsers; coverage 91.99% statements /
87.34% functions / 80.44% branches.
…validation guide

Update the plop generator templates so new components are accessible by default:
component.template.hbs extends NysFormControlElement (form-related) or NysElement
(presentational) from @nysds/internals instead of LitElement, dropping the local
id counter and the hand-rolled ElementInternals boilerplate in favor of the
shared helpers (setFormValue/setValidityFromState/clearValidity) and documenting
the aria-labelledby/aria-errormessage association pattern. package.template.hbs
adds the @nysds/internals devDependency and sideEffects:true; tsconfig.template.hbs
adds the project reference to ../internals. Verified by scaffolding throwaway
form + presentational components and typechecking them against the internals types.

Also correct the MCP form-validation guide: NYSDS form components associate errors
via aria-errormessage (not aria-describedby) and names via aria-labelledby.
…remediation

# Conflicts:
#	custom-elements.json
#	packages/nys-backtotop/src/nys-backtotop.ts
#	packages/nys-datepicker/src/nys-datepicker.ts
#	packages/nys-dropdownmenu/src/nys-dropdownmenu.ts
#	packages/nys-globalfooter/src/nys-globalfooter.ts
#	packages/nys-toggle/package.json
#	packages/react/NysDropdownMenu.d.ts
#	packages/react/nysds-jsx.d.ts
…essage

The merge with develop brought in datepicker tests asserting the pre-remediation
aria-describedby pattern and single-dash error id (${id}-error). Update them to
the remediated aria-errormessage mechanism and the ${id}--error convention used
across all migrated form controls.
@esteinborn

Copy link
Copy Markdown
Contributor

currently failing to build any packages:
image

The form controls' self-registration imports (import "@nysds/nys-label" /
"@nysds/nys-errormessage") are the first source-level sibling imports in the
repo; prior cross-component imports lived only in excluded .stories.ts. Under
`tsc -b tsconfig.build.json` (clean CI), TypeScript needs an explicit project
reference to build those packages first, otherwise their .d.ts are absent and
resolution fails (TS2307). Local builds masked this via stale dist artifacts.

Add the missing references to the 9 form controls that self-register, and wire
the plop templates to scaffold them (+ the deps) for new form-related components
so the regression can't recur.
clean:dist removed dist/ but left the per-package tsconfig.tsbuildinfo files
(they sit next to tsconfig.json, not inside dist/). With dist gone but buildinfo
retained, `tsc -b` considers a project up-to-date and skips emitting — which is
now catastrophic because @nysds/internals is a shared referenced project at the
root of the graph: a stale internals buildinfo means its .d.ts is never emitted
and every consumer fails to resolve "@nysds/internals". Delete the buildinfo
alongside dist (preserving mcp-server) so clean:dist + build is reliable.
@plasticmind

plasticmind commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@esteinborn Good catch. I'm going to assume you got that when running locally. Turns out there's a tsc build cache that gets put into the component folder. It's .gitignored, but can bite you with a new shared package (like the new @Internals one). That's why it wouldn't show up when building in a new environment, but I've updated clean:dist to clean these out so that we shouldn't have this problem going forward on local.

To see this in action, pull the latest and run npm run clean:dist && npm run build:packages

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants