@asanka-npm/a11y-gate
v0.1.1
Published
> WCAG 2.1 Level AA compliance for legacy React/Next.js applications — without modifying internal component states.
Readme
A11y Gate
WCAG 2.1 Level AA compliance for legacy React/Next.js applications — without modifying internal component states.
A11y Gate is a pnpm monorepo containing two packages:
@a11y-gate/cli— dev dependency. AST scanner + interactive config generator.@a11y-gate/core— runtime dependency. React Provider + Shield wrapper components.
For a full breakdown of which WCAG 2.1 criteria are currently supported, partially supported, or pending, see ACCESSIBILITY.md.
Architecture
flowchart TD
subgraph devTime [Dev Time]
A["npx a11y-gate scan"] -->|"TS-Morph AST"| B["raw-metadata.json"]
B -->|"Developer annotates intent\n(LLM via IDE or manual)"| C["proposed-manifest.json"]
C -->|"npx a11y-gate review"| D["a11y-gate.config.json"]
end
subgraph runtime [Runtime]
D -->|"imported at app root"| E["A11yGateProvider config={...}"]
E --> F["Context + Escape listener + aria-hidden manager"]
G["Shield component='LegacyDrawer'"] -->|"reads config from context"| F
G -->|"cloneElement + prop spy"| H["LegacyDrawer isOpen={...} onClose={...}"]
endMonorepo Structure
/a11y-gate
├── packages/
│ ├── cli/ # @a11y-gate/cli (devDependency)
│ │ ├── src/
│ │ │ ├── scan.ts # TS-Morph scanner → raw-metadata.json
│ │ │ └── review.ts # Commander.js interactive review → a11y-gate.config.json
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── core/ # @a11y-gate/core (runtime dependency)
│ ├── src/
│ │ ├── Provider.tsx # A11yGateProvider (app root wrapper)
│ │ ├── Shield.tsx # Shield HOC (per-component wrapper)
│ │ ├── types.ts # A11yGateConfig, ComponentManifest interfaces
│ │ └── utils/
│ │ ├── trap.ts # Sentinel-node focus trap
│ │ ├── inert.ts # aria-hidden + inert background management
│ │ ├── tab-order.ts # Tab-order patching (tabindex, role, aria-label)
│ │ ├── tab-order-preview.ts # Dev-mode numbered tab-order badges
│ │ ├── css-inject.ts # Scoped CSS overrides (WCAG 1.4.4, 1.4.12)
│ │ └── contrast.ts # Per-element contrast enforcement (WCAG 1.4.3)
│ ├── package.json
│ └── tsconfig.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── package.json
└── a11y-gate.config.json # Source of truth (human-verified)Config Schema
raw-metadata.json — CLI output, developer/LLM input
[
{
"componentName": "LegacyDrawer",
"path": "src/components/layout/Drawer.tsx",
"props": ["isOpen", "title", "onClose", "width"]
}
]a11y-gate.config.json — human-verified source of truth
{
"version": "1",
"tab_order_preview": true,
"tab_order_preview_production": false,
"tab_order_preview_scope": "both",
"components": [
{
"componentName": "LegacyDrawer",
"componentType": "side-tray",
"triggerProp": "isOpen",
"closeHandler": "onClose",
"rootSelector": "#root",
"tabOrder": {
"auto": true,
"patches": [
{
"selector": "[data-action='close']",
"role": "button",
"label": "Close"
}
]
}
}
]
}Valid componentType values: "modal", "side-tray", "tooltip", "popover".
tab_order_preview shows numbered tab-order tooltips in development. It is disabled
in production unless tab_order_preview_production is also true.
tab_order_preview_scope controls where badges appear:
"overlay"(default): only inside activeShieldoverlays"global": across the whole page"both": whole page plus active overlays
tabOrder.auto conservatively patches legacy interactive elements by adding
tabIndex=0 in DOM order. tabOrder.patches lets you explicitly target legacy
elements that need keyboard reachability. A11y Gate does not use positive
tabindex values.
Consumer API
// main.tsx / _app.tsx — app root
import config from './a11y-gate.config.json';
import { A11yGateProvider } from '@a11y-gate/core';
<A11yGateProvider config={config}>
<App />
</A11yGateProvider>// At each legacy component call site — per-component wrapper
import { Shield } from '@a11y-gate/core';
<Shield component="LegacyDrawer">
<LegacyDrawer isOpen={open} onClose={close} title="Settings" />
</Shield>Integration
The CLI can create a11y-gate.config.json inside a separate or legacy project. Run the commands from the target project root.
1. Install Packages
For local .tgz testing:
npm install --save <local-path>/a11y-gate/packages/core/a11y-gate-core-0.1.0.tgz
npm install --save-dev <local-path>/a11y-gate/packages/cli/a11y-gate-cli-0.1.0.tgzAfter publishing to npm:
npm install @a11y-gate/core
npm install -D @a11y-gate/cli2. Scan The Project
npx a11y-gate scan --src componentsUse the directory where the legacy React components live, such as src, app, or components. This creates raw-metadata.json.
3. Generate Config
npx a11y-gate reviewThis reads raw-metadata.json, asks which components should be managed, and writes a11y-gate.config.json in the target project.
4. Add The Root Provider
In a Next.js App Router project, create a client wrapper:
"use client"
import { A11yGateProvider } from "@a11y-gate/core"
import config from "@/a11y-gate.config.json"
import type { ReactNode } from "react"
export function A11yGate({ children }: { children: ReactNode }) {
return <A11yGateProvider config={config}>{children}</A11yGateProvider>
}Then wrap the app in app/layout.tsx:
<A11yGate>
{children}
</A11yGate>5. Wrap Legacy Overlays
import { Shield } from "@a11y-gate/core"
<Shield component="DowngradePopup">
<DowngradePopup
isOpen={showDowngradePopup}
onClose={handleDowngradeClose}
/>
</Shield>The component value must match componentName in a11y-gate.config.json.
6. Enable Tab-Order Preview
{
"version": "1",
"tab_order_preview": true,
"tab_order_preview_production": false,
"tab_order_preview_scope": "both",
"components": []
}Scopes:
"overlay": show badges only in activeShieldoverlays"global": show badges across the whole page"both": show global page badges and overlay badges
7. Run And Verify
npm run devVerify that numbered tab-order badges appear in development, wrapped dialogs trap focus, Escape closes wrapped overlays, and background content receives aria-hidden + inert while overlays are open.
Tooling
| Concern | Tool |
|---|---|
| Monorepo | pnpm workspaces |
| CLI bundler | tsup (CJS for Node) |
| Core bundler | tsup (ESM + CJS, <5kb gzipped) |
| AST parsing | ts-morph |
| CLI framework | commander.js |
| TypeScript | shared tsconfig.base.json |
| Versioning | Changesets |
Key Implementation Details
Shield.tsxusesReact.cloneElementto spy ontriggerProp; whentrue, activates focus trap and registers itscloseHandlercallback with Provider contexttrap.tsinjects two invisible sentinel<span tabIndex={0}>nodes around the child subtree; cycles focus on Tab/Shift+Tab. UsesuseId()for ARIA IDs to prevent Next.js hydration mismatchestab-order.tscan patch legacy interactive elements with missing tab stops by adding reversibletabIndex=0attributes before focus trapping runstab-order-preview.tsrenders numbered development-only tooltips that show the effective tab order while an overlay is openinert.tsappliesaria-hidden="true"+inertto therootSelectorelement when any overlay is active; removes both on deactivationcss-inject.tsinjects a scoped<style>tag targeting[data-a11y-gate="ComponentName"] *with!importantoverrides for minimum font size (WCAG 1.4.4) and text spacing (WCAG 1.4.12); cleaned up on overlay closecontrast.tstraverses visible text-bearing elements at overlay-open time, resolves effective background by walking the DOM upward past transparent ancestors, and applies the minimum inline color adjustment needed to reach the WCAG 1.4.3 contrast ratio target via binary search; restores originals on closeProvider.tsxmanages a stack of active overlays; globalkeydownlistener maps Escape to the top-of-stack registeredcloseHandlercallback- LLM integration is intentionally left to the developer/IDE — the CLI is LLM-agnostic
- The library is read-only with respect to the legacy project — it never modifies source files
Build Phases
Phase 1 — Monorepo Scaffolding
- Init pnpm workspace, root
package.json,pnpm-workspace.yaml,tsconfig.base.json
Phase 2 — CLI Package (@a11y-gate/cli)
scan.ts: walksrc/, identify React components (functional + class), extract prop interface keys →raw-metadata.jsonreview.ts: interactive CLI to annotate raw metadata →a11y-gate.config.json
Phase 3 — Core Package (@a11y-gate/core)
types.ts:A11yGateConfig,ComponentManifestTypeScript interfacesProvider.tsx: context, overlay stack, global Escape handler, Shield registration APIShield.tsx: config lookup by name, prop spy viacloneElement, activates trap + inert on triggerutils/trap.ts: sentinel-node focus trap withuseId()utils/inert.ts: background inertness management
Phase 4 — Build & Package
- Configure tsup for both packages
peerDependenciesfor React incore/package.jsonexportsmap,filesfield,publishConfigfor npm publishing- Changesets for monorepo version management
- Verify
corebundle size target (<5kb gzipped) - README with install + usage instructions for both packages
