ibm-a11y-testing-guide
Skill8 starsChanged 3 months agoMITView on GitHub
Reference guide for writing and running Langflow frontend accessibility tests with axe (Jest) and IBM Equal Access (Playwright `page.runA11yScan`). Covers which engine/test layer to pick, the POUR checklist, axe-vs-IBM rule gaps, Radix/AG-Grid component gotchas, and IBM baselines. Use when writing or reviewing a11y tests, debugging a specific axe/IBM violation, or deciding which test layer fits a UI surface. Does not run scans, audit a whole surface, or fix a PR end-to-end — see ibm-a11y-route-scan, ibm-a11y-level1-audit, and ibm-a11y-pr-remediation for those.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- langflow-ai/langflowword for word, first seen Oct 8, 2026
Other versions1 project uses a version of this.
Word for word · 1
- langflow-ai/langflow★ 156k100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository13 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- .cursor/rules/docs_development.mdcCursor rule
- backend-code-reviewSkill
- component-refactoringSkill
- e2e-testingSkill
- frontend-code-reviewSkill
- frontend-i18nSkill
- frontend-query-mutationSkill
- frontend-testingSkill
- ibm-a11y-level1-auditSkill
- ibm-a11y-pr-remediationSkill
- ibm-a11y-route-scanSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
15 rules. About 0 are common, 8 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈8
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 12
Use this skill for frontend work under src/frontend when the change affects:
- Distinctive: line 29
Do not stop at "run axe on default render." Inspect the changed UI and cover every meaningful user-visible state, for both keyboard and …
- Distinctive: line 60
Read the changed files and nearby tests.
- Distinctive: line 73
Default, populated, empty, loading, error/validation visible
- Distinctive: line 86
Every control is reachable AND operable by keyboard (Enter/Space activate.
- Very specific: line 92
After async transitions (route change, panel open/close) focus lands somewhere useful, never silently on <body>.
- Very specific: line 102
No tabbable element with a non-widget role (presentation/none).
- Very specific: line 137
Disabled controls: keep them out of the tab order.
- Very specific: line 139
Custom clickable divs/cells: prefer a semantic element.
- Very specific: line 147
Keep selection UI in sync: after node.setSelected, update React selection state so toolbar actions (e.g.
- Distinctive: line 155
Use Jest when the changed surface is a primitive or component that renders in jsdom without full app routing.
- Distinctive: line 176
Use Playwright when the surface needs real browser layout, routing, app state, mocking, modals, tables, focus order, keyboard behavior, or …
- Very specific: line 210
Run the scan once — it writes the full report to coverage/accessibility-reports/{label}.json.
- Distinctive: line 226
Don't force states the page can't enter.
- Distinctive: line 238
Test files added or updated, and states scanned.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
IBM/axe Accessibility Testing Guide
Reference material for how to test a given UI surface. This skill does not decide what to scan or drive a fix loop by itself — use it while writing tests, debugging a violation, or implementing a fix identified by ibm-a11y-route-scan, ibm-a11y-level1-audit, or ibm-a11y-pr-remediation.
When To Use
Use this skill for frontend work under src/frontend when the change affects:
- UI components, primitives, pages, routes, dialogs, drawers, popovers, dropdowns, tabs, accordions, tables, forms, navigation, or canvas controls.
- ARIA attributes, labels, roles, focus management, tab order, keyboard interaction, disabled states, loading states, or error states.
- A request to write, fix, or review an axe/IBM accessibility test.
Use alongside:
frontend-testingfor Jest/React Testing Library patterns.e2e-testingfor Playwright patterns.frontend-i18nwhen adding or changing user-facing labels, accessible names,aria-label, tooltips, or visible strings.ibm-a11y-route-scanto batch-scan routes with the Python scanner and produce Markdown/HTML reports.ibm-a11y-level1-auditfor a scoped IBM Level 1 compliance audit and report.ibm-a11y-pr-remediationto scan and fix an entire PR/branch end-to-end.
Goal
Do not stop at "run axe on default render." Inspect the changed UI and cover every meaningful user-visible state, for both keyboard and screen-reader users, that can expose accessibility bugs.
The Bar: two engines, both must pass
Automated a11y is not one tool. Run BOTH — they catch different classes of bug:
- axe-core — Jest
axe()(@/utils/a11y-test), jsdom-only. Fast; strong on contrast, labels, roles, ARIA basics. - IBM Equal Access — the stricter engine on ARIA structure and keyboard semantics; catches real WCAG Level-1 issues axe silently passes. Two entry points, same engine (
aChecker):- Playwright
page.runA11yScan(label)— runsaChecker.getCompliancein-browser on the live DOM, so it scans open modals / menus / selected / editing states. This is IBM, not axe (despite the name). scripts/a11y/a11y_scan.py(seeibm-a11y-route-scan) — route scanner for the default-loaded page only.
- Playwright
A page that is green on axe is NOT done. When the change touches a table/tree/listbox/menu/composite widget, IBM is mandatory. Prefer page.runA11yScan for stateful surfaces (it sees the DOM after your interactions); use the Python scanner for a quick default-load route check.
Verify commands:
cd src/frontend
RUN_A11Y=true RUN_A11Y_ASSERT=true npx playwright test tests/a11y/<feature>.a11y.spec.ts --project=chromium --workers=5
# The Python scanner's playwright deps are NOT in the default `uv sync`.
# One-time: `uv run --with playwright playwright install chromium`
uv run --with playwright python scripts/a11y/a11y_scan.py \
--url http://localhost:3000 \
--routes /settings/<route> \
--out /tmp/a11y.json --markdown /tmp/a11y.md --timeout-ms 45000RUN_A11Y=true runs the scan; add RUN_A11Y_ASSERT=true to actually fail on new violations (without it the scan is informational). Both engines must report zero. After changing a shared component, re-scan every page that uses it, not just the one you touched.
First Pass
- Read the changed files and nearby tests.
- Identify the UI surface:
- Primitive/component only
- Composed component
- Page/route
- Stateful workflow
- Shared helper that changes labels, roles, tab order, or focus behavior
- List interactive surfaces:
- Buttons, links, inputs, checkboxes, switches, radios, selects, comboboxes
- Dropdown menus, popovers, dialogs, drawers, tooltips
- Tabs, accordions, tables/treegrids, row actions, pagination
- Keyboard-only interactions and focus traps
- List states worth scanning:
- Default, populated, empty, loading, error/validation visible
- Disabled/read-only, selected/expanded/open
- Modal/dropdown/popover open
- Mobile viewport when layout changes
What To Check (POUR checklist)
Perceivable
- Non-text content has a text alternative; decorative images are hidden from AT.
- Text contrast ≥ 4.5:1; non-text (icons, borders, focus rings) ≥ 3:1.
- Layout reflows at 320px / 400% zoom without horizontal scroll or loss of content.
Operable (keyboard is the big one)
- Every control is reachable AND operable by keyboard (Enter/Space activate; arrows where expected).
- Focus is always visible.
- Focus order is logical and matches visual order.
- No keyboard trap — test Shift+Tab too, not just Tab. Focus can always move out of a widget both ways.
- Disabled controls are not in the tab order.
- Composite widgets (grid/tree/listbox/menu/tablist) are ONE logical tab stop with a roving
tabindex; arrows move within. - After async transitions (route change, panel open/close) focus lands somewhere useful, never silently on
<body>.
Understandable
- Inputs have programmatic labels; visible label matches accessible name.
- Errors are identified in text and associated with their field; give a suggestion when possible.
- The same control is identified consistently across the app.
Robust (name / role / value)
- Interactive elements expose a correct role, name, and state.
- Valid ARIA parent/child (e.g.
rowgroupownsrow,listownslistitem). - No tabbable element with a non-widget role (
presentation/none). aria-hidden="true"never wraps a focusable element.
axe vs IBM: known gaps
Things axe passes but IBM flags (all real WCAG Level 1):
| IBM rule | WCAG | Typical cause |
element_tabbable_role_valid | 4.1.2 | tabbable element with role="presentation" / no widget role (focus sentinels, wrappers) |
aria_child_valid | 1.3.1 | rowgroup/list/tablist owning no valid child role |
aria_child_tabbable | 2.1.1 / 4.1.2 | composite widget with no tabbable descendant (needs roving tabindex) |
aria_hidden_focus_misuse | 4.1.2 | focusable element inside aria-hidden="true" |
aria_accessiblename_exists | 4.1.2 | element with a widget role but no accessible name — e.g. an AG Grid columnheader with neither field nor headerName (icon-only action column) |
aria_content_in_landmark | 1.3.1 | content outside any landmark — e.g. a Radix menu/popover portaled to <body>. role="menu" is NOT landmark-exempt; only aria-modal dialogs are. Often unfixable app-wide → baseline it (see below) |
Rule of thumb: touch a table/tree/listbox/menu → scan with IBM.
Component Gotchas
- Data grids (AG Grid — the shared
TableComponent): the grid's own tab guards + pagination are the fragile part.- Disabled paging buttons must be
tabindex="-1", but NEVERinert/disabled— that breaks AG Grid's tab guards and traps reverse (Shift+Tab) entry (WCAG 2.1.2). - AG Grid still programmatically focuses disabled paging on tab-out; handle with its
tabToNextCellhook plus a container-scopedfocusinredirect (defer the redirect torequestAnimationFrame— focus changes inside afocusinhandler are ignored, and AG Grid restores the last-focused cell on the next tick). - Empty rowgroups need
role="presentation"; the grid needs one roving-tabindex row. - Icon-only / action column header: AG Grid derives the
columnheaderaccessible name fromfield; a column with neitherfieldnorheaderNameis nameless (aria_accessiblename_exists, 4.1.2). Give it aheaderNameand hide it visually with aheaderClass(sr-only clip on.ag-header-cell-text) so the header stays blank but named. - Interactive control inside a cell (menu trigger, button): AG Grid navigates cell-by-cell and never tab-focuses the inner control, so it's keyboard-inoperable. Activate it from the grid's
onCellKeyDownon Enter/Space (give the column acolIdto target it). A Radix trigger opens on keydown/pointerdown, not a synthetic.click()— focus the trigger and re-dispatch the key (new KeyboardEvent("keydown", {key, bubbles:true})), guarding against re-entry (skip whentargetis already the button).TableComponentspreads{...props}to AG Grid, so passonCellKeyDownfrom the page — no shared-component change. - Focus visible on borderless grids (
.ag-no-border): the theme suppresses the cell focus outline (outline:none), hiding keyboard focus (2.4.7). Restore it with a:focus-visiblering scoped to.ag-no-border .ag-cell:focus-visible(+ header/row).:focus-visibledistinguishes modality on AG cells — mouse click resolves to:focus, keyboard nav to:focus-visible— so the ring shows for keyboard only and mouse stays quiet. CAVEAT when verifying: probe with a clean, first-interaction page; a session that already used the keyboard makes:focus-visiblereporttruefor a later mouse click (heuristic contamination). - Set
ensureDomOrder: trueso DOM order matches visual order for tab navigation. TableComponentis shared across settings/traces — fix once in the grid patch, then re-scan every grid page. Its pagination/tab-out rework lives in LE-1720 / PR #13953; don't duplicate it per-page.
- Disabled paging buttons must be
- Modals/drawers: title + description, focus trapped inside while open, focus restored to the trigger on close, Escape closes.
- Dropdowns/popovers: reachable by keyboard, Escape closes, focus returns to the trigger.
- Radix (shadcn) primitives — three recurring traps:
Trigger asChilddouble tab stop: aDialogTrigger/DropdownMenuTriggerwrapping a real<button>withoutasChildmakes Radix render its own<button>around it → nested buttons → two consecutive tab stops (2.4.3). Always passasChildwhen the child is already an interactive element. (Watch the trigger wrapper's ownasChild={cond}default —DeleteConfirmationModalneeds an explicitasChild.)- Focus-restore race: on close, Radix restores focus to its trigger once, asynchronously. If the menu action programmatically moves focus elsewhere (e.g.
startEditingCellopens an inline editor), Radix steals it back. Re-assert focus on your target across a fewrequestAnimationFrames to outlast the one-time restore. For rename-style flows,.select()the input too. - Portaled menu landmark:
DropdownMenu/Popovercontent portals to<body>, trippingaria_content_in_landmark. Portaling into<main>is not an option when<main>isoverflow-hidden(clips the menu). Treat as app-wide debt → baseline it (below); the menu's real a11y (role, named trigger, keyboard open/close, focus restore) is covered by a keyboard test instead.
- Disabled controls: keep them out of the tab order. Only use
aria-disabled(instead of nativedisabled) when the control must stay discoverable, and keep it non-operable. - Icon-only buttons: real
<button>with anaria-label. - Custom clickable divs/cells: prefer a semantic element; if a cell/card must open something it needs Enter/Space handling, not just
onClick.
Best practices (settings grids + modals)
Apply these when building or reviewing AG Grid tables that open an edit modal (reference: GlobalVariablesPage, tests/a11y/global-variables.a11y.spec.ts):
- Keyboard map on selectable rows: Space toggles the row checkbox; Enter opens the row’s edit/detail UI. Do not use Space to open the modal.
- Scope the map on the page: implement with page-level
onCellKeyDownand per-columnsuppressKeyboardEventfor Enter/Space so AG Grid’s default Space handling does not compete. Prefer not changing sharedTableComponentdefaults unless the same map is product-wide. - Keep selection UI in sync: after
node.setSelected, update React selection state so toolbar actions (e.g. delete) enable/disable correctly (TableOptions.hasSelectionis read at render time). - Retain place on modal close (2.4.3): remember the focused cell (
rowIndex+colId) when opening edit; on close (Escape, Cancel, or save) restore withapi.setFocusedCell+ DOM.focus(), using a fewrequestAnimationFrames to outlast Radix dialog cleanup. Controlled edit dialogs without a Radix trigger need this explicitly. Create flows that use a realDialogTrigger(e.g. Add New) should restore to that trigger. - Verify with keyboard tests: open from a cell → Escape → focus is still on that cell; Enter can open again without a mouse re-click.
Choose Test Layer
Jest + axe
Use Jest when the changed surface is a primitive or component that renders in jsdom without full app routing.
import { render, screen } from "@testing-library/react";
import { axe } from "@/utils/a11y-test";
it("should_have_no_axe_violations", async () => {
const { container } = render(<Component />);
expect(await axe(container)).toHaveNoViolations();
});Notes: a11y-test.ts disables color-contrast (jsdom can't measure layout). Prefer role/label/text queries. Assert accessible names explicitly for icon-only / visually-hidden / generated labels. Put tests at __tests__/<name>.a11y.test.tsx.
cd src/frontend
npx jest path/to/<name>.a11y.test.tsx --runInBandPlaywright + page.runA11yScan
Use Playwright when the surface needs real browser layout, routing, app state, mocking, modals, tables, focus order, keyboard behavior, or full-page interactions.
import { expect, test } from "../fixtures";
import { awaitBootstrapTest } from "../utils/await-bootstrap-test";
test("scans populated state", { tag: ["@release", "@api"] }, async ({ page }) => {
await awaitBootstrapTest(page, { skipModal: true });
await page.goto("/settings/example");
await expect(page.getByText("Example")).toBeVisible();
await page.runA11yScan("settings-example-populated");
});Rules: put specs under tests/a11y/<feature>.a11y.spec.ts; import test/expect from ../fixtures; tag every test @release plus a domain tag (@workspace/@api/@database/@components/@starter-projects); stable scan names (lowercase, feature-first, state-specific); mock API for deterministic states; disable animations for focus assertions; use explicit interactions (no random clicking of destructive controls). If the route belongs in static coverage, update scripts/a11y/a11y_routes.json.
cd src/frontend
RUN_A11Y=true npx playwright test tests/a11y/<feature>.a11y.spec.ts --project=chromium --workers=5
RUN_A11Y=true RUN_A11Y_ASSERT=true npx playwright test tests/a11y/<feature>.a11y.spec.ts --project=chromium --workers=5
npm run a11y:html-report --silentIBM baselines (accepting known debt)
Some IBM violations are real but framework-level and can't be fixed per-page (e.g. aria_content_in_landmark on a Radix menu portal). Track them with an IBM baseline so the scan stays green while the debt is recorded — do NOT silently drop the scan or the assertion.
How it works (fixtures.ts + .achecker.yml):
baselineFolder: tests/a11y/baselines. The baseline file name is the scan label:{project}__{label}.json, e.g.chromium__assets-files-actions-menu.json(dashes preserved; the first scan in a test has no index suffix).- On each run IBM's
filterReportloads that file and marks any result matching a baseline entry bypath.dom+ruleId+reasonIdasignored: true.countNewA11yViolationscountsviolation && !ignored, so baselined issues don't fail.
Create one:
- Run the scan once — it writes the full report to
coverage/accessibility-reports/{label}.json. - Copy the offending result(s) into
tests/a11y/baselines/{label}.jsonas{ "results": [ { "ruleId", "reasonId", "path": { "dom": … } } ] }. A minimal baseline (only the entries to ignore) is clearest; add adescriptionfield noting why and how to resurface it. - Re-run → the scan passes. Commit the baseline so the debt is tracked; deleting it resurfaces the violation. Point at the baseline from a comment in the spec.
Matching is exact-DOM-path, so baselines can go stale if unrelated <body>-level portals (toasts) shift the portal index — scan the state with no other overlays open.
Route/Page Coverage Matrix
For route/page changes, cover the smallest matrix representing real user states:
- Loaded/default, empty data, populated data
- Primary modal open, dropdown/popover open
- Validation or error visible
- Selected/expanded table row or bulk-action state
- Mobile viewport when responsive layout changes
Don't force states the page can't enter; briefly explain any skipped state.
Data-Rich Route Pattern
Use tests/a11y/api-keys.a11y.spec.ts and tests/a11y/files.a11y.spec.ts as the models for route-level work: mock API data; scan populated table; scan empty table; open create modal and scan; submit to the generated-result modal and scan; open a text-cell modal and scan; select a row and scan the selected state; set a mobile viewport and scan. Add keyboard-interaction tests (not scans) for tab-out, reverse re-entry, Enter-to-open, focus restore, and focus-visible where the surface has custom keyboard behavior. This is the expected bar for data-rich routes.
Driving grid row states: client-rendered states (upload progress %, upload-failed/error, disabled rows) are usually driven by fields the cell renderer reads off the row (params.data.*). If the list query returns the response untransformed, just inject the field into the mocked rows (e.g. { ...file, progress: -1 }) to scan the error/progress state — no need to simulate the real flow.
Report Back
When done, state:
- Test files added or updated, and states scanned.
- Commands run — including whether BOTH axe and IBM were run and both reported zero.
- Any states not covered and why.
- Any remaining a11y risk or accepted limitation.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

