@afixt/apg-react
v2.3.0
Published
Accessible React components implementing every pattern in the WAI-ARIA Authoring Practices Guide (APG)
Readme
apg-react
Accessible React components implementing every pattern in the W3C ARIA Authoring Practices Guide (APG).
Each component ships with the full APG keyboard-interaction model, correct ARIA
roles and state, focus management, and a Bootstrap-flavored visual default you
can restyle via CSS custom properties. Written in TypeScript with full type
declarations. All user-facing strings are translatable via an optional labels
prop.
Why
Most component libraries treat accessibility as a checklist. This library treats the APG as the specification. Every component is tested against the APG's keyboard model, ARIA contract, and focus-management requirements — with 291 unit tests, 37 dedicated accessibility-contract tests, and E2E tests driving a real browser. Every assertion is implemented from first principles against the DOM.
Install
npm install @afixt/apg-reactPeer dependencies:
react≥ 18react-dom≥ 18
There are no other runtime dependencies. In particular, this library does not depend on a router — see Router integration if you use one.
Quick start
import { Button, Accordion, ModalDialog } from '@afixt/apg-react';
import '@afixt/@afixt/apg-react/styles.css'; // full baseline styles
// or, to cherry-pick tokens only:
// import '@afixt/@afixt/apg-react/variables.css';
function App() {
return <Button label="Save" action={() => save()} />;
}Components are tree-shakeable; only what you import will land in your bundle.
Components
Components are organized by the APG pattern they implement. Follow each link for the official APG documentation.
Widgets
| Component | APG pattern |
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| Accordion | Accordion |
| Alert | Alert |
| AlertDialog | Alert Dialog |
| Breadcrumb | Breadcrumb |
| Button | Button |
| Carousel | Carousel |
| Checkbox | Checkbox (dual & tri-state) |
| CheckboxGroup | Checkbox — parent/child mixed state |
| Combobox | Combobox — supports none, list, both |
| Disclosure | Disclosure |
| Feed | Feed |
| Grid | Grid |
| Link | Link pattern (see Router integration) |
| Listbox | Listbox — single & multi-select |
| MenuButton | Menu Button |
| Menubar | Menu / Menubar |
| Meter | role=meter |
| ModalDialog | Dialog (Modal) |
| NonModalDialog | Dialog (Modal) — non-modal: no focus trap, no backdrop |
| Progressbar | role=progressbar |
| RadioGroup | Radio Group |
| Slider | Slider |
| SliderMultiThumb | Slider (Multi-Thumb) |
| Spinbutton | Spinbutton |
| Switch | Switch |
| Tabs | Tabs — automatic or manual activation, horizontal or vertical |
| Textbox | role=textbox — single- and multi-line |
| Toolbar | Toolbar |
| Tooltip | Tooltip |
| TreeGrid | Tree Grid |
| TreeView | Tree View |
| WindowSplitter | Window Splitter |
Structural
| Component | Purpose |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Article | Semantic <article> with heading + posinset/setsize for use inside Feed. Focusable and in the page tab sequence, as the Feed pattern requires. |
Keyboard reference
Every component implements the full keyboard model specified by its APG pattern. Highlights:
- Roving tabindex where the pattern calls for it:
RadioGroup,Toolbar,Tabs,Grid,TreeView,TreeGrid,Menubar,Listbox. aria-activedescendantfor virtual focus:Combobox.- Focus return on dialog dismiss:
ModalDialog,NonModalDialog,AlertDialog,MenuButton,Menubar. - Escape closes popups:
ModalDialog,NonModalDialog(only while focus is inside it),AlertDialog,MenuButton,Menubar,Combobox,Tooltip. - A way out of a long composite:
Feed—Ctrl+HomeandCtrl+Endmove focus to the focusable element before and after the feed, so a keyboard user need not Tab through every article to get past it.
Explore the live Storybook demo or run
npm run storybook locally to see every keyboard path with step-by-step play
interactions.
Styling
The package ships two CSS files:
@afixt/apg-react/styles.css— full baseline styles for every component.@afixt/apg-react/variables.css— only the design tokens, if you want to write your own styles.
All visual choices are driven by CSS custom properties defined in
variables.css. Override them at :root or at a container scope:
:root {
--apg-color-primary: #7c3aed;
--apg-radius-md: 0.25rem;
--apg-font-family: 'Inter', system-ui, sans-serif;
}Key token groups: colors (--apg-color-*), spacing (--apg-space-*), radii
(--apg-radius-*), typography (--apg-font-*), focus ring
(--apg-focus-ring-*), shadows (--apg-shadow-*), z-index (--apg-z-*).
Router integration
Link and Breadcrumb render navigable links. By default they emit a plain
<a href>, so the library never forces a router on you and importing
@afixt/apg-react works with no router installed.
If your app uses a router, supply its link component once at the root and every
descendant Link and Breadcrumb will use it for client-side navigation:
import { Link as RouterLink } from 'react-router-dom';
import { LinkComponentProvider } from '@afixt/apg-react';
<BrowserRouter>
<LinkComponentProvider value={RouterLink}>
<App />
</LinkComponentProvider>
</BrowserRouter>;You can also override it per instance, which takes precedence over the provider:
<Link to="/profile" linkComponent={RouterLink}>
View profile
</Link>
<Breadcrumb items={items} linkComponent={RouterLink} />To send a single link back to a plain anchor inside a provider — an external
URL, a download, a full page load — pass null:
<Link to="https://example.com/report.pdf" linkComponent={null}>
Download the report
</Link>Omitting the prop and passing null are different: omitting defers to the
provider, null opts out of it.
Any component accepting a to prop works — React Router, TanStack Router, or
your own wrapper around a framework's link. Extra props are forwarded verbatim,
which makes the injected component responsible for the other half of the
contract: it must pass onClick, onKeyDown, and the rest through to the
anchor it renders, or the consumer's handlers will never fire.
Without a provider or prop, a to given as a location object is flattened to
pathname + search + hash for the anchor's href.
The fallback puts that value straight into href — the library does not rewrite
or sanitize it, exactly as a hand-written <a href> would not. Up to and
including 1.3.0 every to was resolved by React Router first, so if you
interpolate untrusted input into to, validate it yourself before rendering.
Upgrading from ≤ 1.3.0: these components used to import React Router directly, which made the router a hard requirement for every consumer. If you relied on client-side navigation through them, wrap your app in
LinkComponentProvideras shown above; otherwise navigation will fall back to full page loads.
Internationalization (i18n)
Components that render hardcoded user-facing strings (aria-labels, button text)
accept an optional labels prop — an object whose keys map to English defaults.
Pass your own translations without forking:
<Alert message="Saved" type="info" labels={{ dismiss: "Fermer" }} />
<Carousel slides={slides} labels={{ previousSlide: "Anterior", nextSlide: "Siguiente" }} />
<ModalDialog isOpen onClose={close} labels={{ closeDialog: "Cerrar" }}>…</ModalDialog>
<Spinbutton min={0} max={10} labels={{ increaseValue: "Erhöhen", decreaseValue: "Verringern" }} />
<Breadcrumb items={items} navLabel="Fil d'Ariane" />When labels is omitted, the English defaults are used. Only the keys you
override are affected; the rest keep their defaults.
Implementer responsibilities
A handful of concerns must be satisfied by you — they cannot be handled at the library level:
- Visible focus indicators. The default focus ring uses a soft box-shadow; ensure your brand palette preserves ≥3:1 contrast against the background.
- Color contrast. If you override tokens, verify WCAG 1.4.3 (4.5:1 for text) and 1.4.11 (3:1 for UI).
- External labels. Where a component exposes
ariaLabelledby, make sure the referenced element exists and carries a meaningful name. - Focus restoration on unmount. Dialog components return focus to the invoking element when dismissed, but if you unmount them imperatively, re-establish focus yourself.
- Live regions for dynamic content. Components that produce their own
role=alert/role=statusfire announcements; adjacent dynamic content you write still needs its own live region.
Testing
npm test # unit + a11y contract suite (jsdom)
npm run test:e2e:build # builds Storybook, runs Puppeteer E2E tests
npm run test:all # both- 291 unit tests across 32 suites; 92%+ statement coverage.
- 37 accessibility-contract tests built on a hand-rolled ARIA-aware DOM assertion library — no external a11y libraries of any kind.
- E2E tests drive a real Chromium against a built Storybook: accessible-name
presence,
aria-*id resolution, ARIA boolean grammar, Tab reachability. - Use cases (
usecases/) document every user interaction in the DSL of@afixt/usecase-runnerso the APG keyboard and ARIA contract for each component can be exercised by automation. Seeusecases/README.md.
Development
# First-time setup: installs optional binaries (trufflehog, lychee, etc.)
# used by git hooks. Safe to re-run.
bash scripts/bootstrap.sh
npm install
npm run storybook # http://localhost:6006
npm run serve # http://localhost:8080/demos/index.html
npm test
npm run build # produces dist/Demo server
npm run serve starts the static demo pages in demos/ —
one page per APG pattern, each rendering this library's own component. They are
the target that the AFixt/apg-qa use-case suite and the APG test-runner
repositories assert against, so a demo page never reimplements the pattern it
demonstrates.
Quality gates
Every PR runs the same gates locally and in CI. Run them all with one command:
npm run check:allwhich runs, in order: typecheck → lint → knip → stylelint →
format:check → markdownlint → test → dupes → license:check → build →
size → security:audit.
Individual gates:
npm run typecheck # tsc --noEmit with strict + noUncheckedIndexedAccess
npm run lint # ESLint flat config (components + tests + stories)
npm run knip # Knip: unused files, exports and dependencies
npm run stylelint # Stylelint on all CSS (incl. a11y rules)
npm run format # Prettier --write
npm run format:check # Prettier --check (CI gate)
npm run markdownlint # markdownlint-cli2 on all Markdown
npm run dupes # jscpd duplicate detection
npm run license:check # production-dep license allowlist
npm run size # size-limit bundle budgets
npm run security # npm audit + OSV-Scanner + trufflehogGit hooks (installed automatically via Husky's prepare script):
- pre-commit — lint-staged (ESLint, Prettier, Stylelint, markdownlint on staged files only) + typecheck of staged TS + trufflehog.
- commit-msg — commitlint with
@commitlint/config-conventional. - pre-push — runs the full
checksuite + tests +dupes+license:check+ optional link check. - post-merge — reinstall +
npm auditwhenpackage-lock.jsonchanges after a pull.
Bypass a hook with --no-verify (use sparingly).
Scheduled workflows:
.github/workflows/security.yml(Mondays 06:00 UTC) — CodeQL, OSV-Scanner, Semgrep OWASP Top 10, npm audit..github/workflows/docs.yml(Mondays 07:00 UTC) — lychee link check across Markdown.
See docs/adr/ for tooling decisions (why Jest not Vitest, why Rollup not Vite,
etc.).
Contributing
Issues and PRs are welcome. See CONTRIBUTING.md.
Versioning
This library follows Semantic Versioning. Breaking changes land in major releases; new components and opt-in features in minor; fixes in patch. See CHANGELOG.md.
Cutting a release is documented in docs/RELEASING.md.
License
MIT © AFixt, Inc. — see LICENSE.
