Skip to content

Commit 8cb3972

Browse files
PIE-534 deepen a11y scenario coverage
Add broader per-element Axe scenarios, reusable accessibility checks, and tracking docs so accessibility findings can be triaged consistently across the PIE catalog. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a0b9796 commit 8cb3972

35 files changed

Lines changed: 1083 additions & 2 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
description: Guidance for maintaining the PIE accessibility scenario suite
3+
globs: apps/element-demo/src/lib/a11y/**, apps/element-demo/test/a11y/**, apps/element-demo/src/lib/samples/**, docs/a11y/**
4+
alwaysApply: false
5+
---
6+
7+
# Accessibility Scenario Suite
8+
9+
- Treat WCAG 2.2 Level AA as the target when adding or reviewing PIE element accessibility scenarios.
10+
- Use `.claude/skills/accessibility-reviewer-assessments/SKILL.md` for the detailed assessment-specific checklist.
11+
- Prefer dedicated a11y scenarios in `apps/element-demo/src/lib/a11y/scenarios/catalog.ts` over broad demo inventory coverage.
12+
- Keep automated scope explicit: document Axe-covered checks, custom Playwright checks, manual-only concerns, and unclear gaps in `docs/a11y/`.
13+
- Add reusable checks in `apps/element-demo/test/a11y/axe-scenarios.spec.ts` only when the concern applies across multiple elements.
14+
- Do not edit synced outputs in `packages/elements-react/*` or `packages/lib-react/*`; fix upstream first or document the issue for follow-up.

apps/element-demo/src/lib/a11y/scenarios/catalog.ts

Lines changed: 358 additions & 1 deletion
Large diffs are not rendered by default.

apps/element-demo/src/lib/a11y/scenarios/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ export type A11yConcern =
1818

1919
export type A11yAutomatedCheck =
2020
| 'axe'
21+
| 'group-label'
2122
| 'interactive-control-name'
2223
| 'keyboard-tab-reach'
24+
| 'math-alternative'
25+
| 'media-alternative'
2326
| 'target-size'
2427
| 'status-message';
2528

apps/element-demo/test/a11y/axe-scenarios.spec.ts

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,16 @@ async function runScenarioChecks(
202202
const results: CheckResult[] = [];
203203

204204
for (const check of checks) {
205-
if (check === 'interactive-control-name') {
205+
if (check === 'group-label') {
206+
results.push(await checkGroupLabels(page));
207+
} else if (check === 'interactive-control-name') {
206208
results.push(await checkInteractiveControlNames(page));
207209
} else if (check === 'keyboard-tab-reach') {
208210
results.push(await checkKeyboardTabReach(page));
211+
} else if (check === 'math-alternative') {
212+
results.push(await checkMathAlternatives(page));
213+
} else if (check === 'media-alternative') {
214+
results.push(await checkMediaAlternatives(page));
209215
} else if (check === 'target-size') {
210216
results.push(await checkTargetSize(page));
211217
} else if (check === 'status-message') {
@@ -216,6 +222,73 @@ async function runScenarioChecks(
216222
return results;
217223
}
218224

225+
async function checkGroupLabels(page: Page): Promise<CheckResult> {
226+
const details = await page.locator('[data-testid="a11y-scan-subject"]').evaluate((subject) => {
227+
const groupSelector = [
228+
'fieldset',
229+
'[role="group"]',
230+
'[role="radiogroup"]',
231+
'[role="listbox"]',
232+
'[role="grid"]',
233+
'[role="table"]',
234+
].join(',');
235+
236+
function isVisible(element: Element) {
237+
const rect = element.getBoundingClientRect();
238+
const style = window.getComputedStyle(element);
239+
return (
240+
rect.width > 0 &&
241+
rect.height > 0 &&
242+
style.visibility !== 'hidden' &&
243+
style.display !== 'none'
244+
);
245+
}
246+
247+
function textFromIdRefs(ids: string | null) {
248+
if (!ids) {
249+
return '';
250+
}
251+
return ids
252+
.split(/\s+/)
253+
.map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
254+
.filter(Boolean)
255+
.join(' ');
256+
}
257+
258+
function groupName(element: Element) {
259+
if (element instanceof HTMLFieldSetElement) {
260+
const legend = element.querySelector('legend')?.textContent?.trim();
261+
if (legend) {
262+
return legend;
263+
}
264+
}
265+
266+
return (
267+
element.getAttribute('aria-label')?.trim() ||
268+
textFromIdRefs(element.getAttribute('aria-labelledby')) ||
269+
element.getAttribute('title')?.trim() ||
270+
''
271+
);
272+
}
273+
274+
return [...subject.querySelectorAll(groupSelector)]
275+
.filter(isVisible)
276+
.filter((element) => !groupName(element))
277+
.slice(0, 10)
278+
.map((element) => element.outerHTML.slice(0, 300));
279+
});
280+
281+
return {
282+
check: 'group-label',
283+
status: details.length > 0 ? 'failed' : 'passed',
284+
message:
285+
details.length > 0
286+
? `${details.length} visible group(s) appear to lack a programmatic label`
287+
: 'Visible fieldsets and ARIA groups have label signals',
288+
details,
289+
};
290+
}
291+
219292
async function checkInteractiveControlNames(page: Page): Promise<CheckResult> {
220293
const details = await page.locator('[data-testid="a11y-scan-subject"]').evaluate((subject) => {
221294
const interactiveSelector = [
@@ -345,6 +418,140 @@ async function checkKeyboardTabReach(page: Page): Promise<CheckResult> {
345418
};
346419
}
347420

421+
async function checkMathAlternatives(page: Page): Promise<CheckResult> {
422+
const result = await page.locator('[data-testid="a11y-scan-subject"]').evaluate((subject) => {
423+
const mathSelector = ['math', '.MathJax', '[data-latex]', '[data-math]'].join(',');
424+
425+
function isVisible(element: Element) {
426+
const rect = element.getBoundingClientRect();
427+
const style = window.getComputedStyle(element);
428+
return (
429+
rect.width > 0 &&
430+
rect.height > 0 &&
431+
style.visibility !== 'hidden' &&
432+
style.display !== 'none'
433+
);
434+
}
435+
436+
function textFromIdRefs(ids: string | null) {
437+
if (!ids) {
438+
return '';
439+
}
440+
return ids
441+
.split(/\s+/)
442+
.map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
443+
.filter(Boolean)
444+
.join(' ');
445+
}
446+
447+
const mathNodes = [...subject.querySelectorAll(mathSelector)].filter(isVisible);
448+
const missing = mathNodes
449+
.filter((element) => {
450+
const hasAccessibleName =
451+
!!element.getAttribute('aria-label')?.trim() ||
452+
!!textFromIdRefs(element.getAttribute('aria-labelledby'));
453+
const hasNativeAlternative = !!element.querySelector('annotation, annotation-xml, title');
454+
const hasText = !!element.textContent?.trim();
455+
return !hasAccessibleName && !hasNativeAlternative && !hasText;
456+
})
457+
.slice(0, 10)
458+
.map((element) => element.outerHTML.slice(0, 300));
459+
460+
return { count: mathNodes.length, missing };
461+
});
462+
463+
return {
464+
check: 'math-alternative',
465+
status: result.count === 0 || result.missing.length > 0 ? 'failed' : 'passed',
466+
message:
467+
result.count === 0
468+
? 'No rendered math nodes were found for this math-alternative scenario'
469+
: result.missing.length > 0
470+
? `${result.missing.length} rendered math node(s) appear to lack a text alternative`
471+
: 'Rendered math nodes expose text or accessible-name signals',
472+
details: result.missing,
473+
};
474+
}
475+
476+
async function checkMediaAlternatives(page: Page): Promise<CheckResult> {
477+
const details = await page.locator('[data-testid="a11y-scan-subject"]').evaluate((subject) => {
478+
function isVisible(element: Element) {
479+
const rect = element.getBoundingClientRect();
480+
const style = window.getComputedStyle(element);
481+
return (
482+
rect.width > 0 &&
483+
rect.height > 0 &&
484+
style.visibility !== 'hidden' &&
485+
style.display !== 'none'
486+
);
487+
}
488+
489+
function textFromIdRefs(ids: string | null) {
490+
if (!ids) {
491+
return '';
492+
}
493+
return ids
494+
.split(/\s+/)
495+
.map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
496+
.filter(Boolean)
497+
.join(' ');
498+
}
499+
500+
function hasNameOrDecorativeRole(element: Element) {
501+
if (
502+
element.getAttribute('aria-hidden') === 'true' ||
503+
element.getAttribute('role') === 'presentation' ||
504+
element.getAttribute('role') === 'none'
505+
) {
506+
return true;
507+
}
508+
509+
return (
510+
!!element.getAttribute('aria-label')?.trim() ||
511+
!!textFromIdRefs(element.getAttribute('aria-labelledby')) ||
512+
!!element.querySelector('title')?.textContent?.trim()
513+
);
514+
}
515+
516+
const missing: string[] = [];
517+
518+
for (const image of [...subject.querySelectorAll('img')].filter(isVisible)) {
519+
if (image.getAttribute('alt') === null && !hasNameOrDecorativeRole(image)) {
520+
missing.push(image.outerHTML.slice(0, 300));
521+
}
522+
}
523+
524+
for (const graphic of [...subject.querySelectorAll('svg, canvas')].filter(isVisible)) {
525+
if (!hasNameOrDecorativeRole(graphic)) {
526+
missing.push(graphic.outerHTML.slice(0, 300));
527+
}
528+
}
529+
530+
for (const media of [...subject.querySelectorAll('audio, video')].filter(isVisible)) {
531+
if (!hasNameOrDecorativeRole(media) && media.querySelectorAll('track').length === 0) {
532+
missing.push(media.outerHTML.slice(0, 300));
533+
}
534+
}
535+
536+
return {
537+
count: subject.querySelectorAll('img, svg, canvas, audio, video').length,
538+
missing: missing.slice(0, 10),
539+
};
540+
});
541+
542+
return {
543+
check: 'media-alternative',
544+
status: details.count === 0 || details.missing.length > 0 ? 'failed' : 'passed',
545+
message:
546+
details.count === 0
547+
? 'No media or graphic nodes were found for this media-alternative scenario'
548+
: details.missing.length > 0
549+
? `${details.missing.length} visible media/graphic node(s) appear to lack an alternative or decorative marker`
550+
: 'Visible media and graphics expose alternatives or decorative markers',
551+
details: details.missing,
552+
};
553+
}
554+
348555
async function checkTargetSize(page: Page): Promise<CheckResult> {
349556
const details = await page.locator('[data-testid="a11y-scan-subject"]').evaluate((subject) => {
350557
const selector = [

docs/a11y/TRACKING.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# PIE Element A11y Tracking
2+
3+
Status: Initial scenario coverage in progress.
4+
5+
This directory tracks the WCAG 2.2 Level AA accessibility coverage for every PIE element in the demo registry. The automated suite lives in `apps/element-demo/src/lib/a11y/scenarios/catalog.ts` and runs through `apps/element-demo/test/a11y/axe-scenarios.spec.ts`.
6+
7+
## Coverage Matrix
8+
9+
| Element | Scenario Coverage | Automated Focus | Remaining Manual / Unclear Work |
10+
| --- | --- | --- | --- |
11+
| [categorize](categorize.md) | Drop-zone names; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm full drag/drop keyboard workflow and screen-reader placement announcements. |
12+
| [charting](charting.md) | Editable bar, line, histogram | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm data values and chart trends are understandable without sight or color. |
13+
| [complex-rubric](complex-rubric.md) | Rubric structure; evaluate feedback | Axe, labelled groups, control names, tab reach, status messages | Confirm long descriptors are announced in a usable order. |
14+
| [drag-in-the-blank](drag-in-the-blank.md) | Blank targets; image word problem | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm drag/drop has a complete keyboard alternative and clear live feedback. |
15+
| [drawing-response](drawing-response.md) | Toolbar controls; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, media alternatives, status messages | Confirm non-pointer drawing alternatives and canvas result descriptions. |
16+
| [ebsr](ebsr.md) | Two-part choice groups; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm Part A/Part B relationship is clear to screen readers. |
17+
| [explicit-constructed-response](explicit-constructed-response.md) | Embedded fields; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm embedded blank instructions remain clear in long rich text. |
18+
| [extended-text-entry](extended-text-entry.md) | Editor labels; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm rich-text/editor keyboard shortcuts and screen-reader mode behavior. |
19+
| [fraction-model](fraction-model.md) | Segment controls; configurable/improper models | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm fraction visuals have equivalent text meaning. |
20+
| [graphing](graphing.md) | Toolbar; parabola graph | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm graph construction can be completed without pointer input. |
21+
| [graphing-solution-set](graphing-solution-set.md) | Solution region; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, media alternatives, status messages | Confirm shaded solution regions do not rely on color or position alone. |
22+
| [hotspot](hotspot.md) | Hotspot region names | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm hotspot labels are descriptive without revealing answers. |
23+
| [image-cloze-association](image-cloze-association.md) | Image targets and responses | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm image/drop target relationship is understandable without sight. |
24+
| [inline-dropdown](inline-dropdown.md) | Combobox blanks; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm dropdown open/close and option navigation behavior with screen readers. |
25+
| [likert](likert.md) | Scale radio group; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm scale endpoints and selected state are announced clearly. |
26+
| [match](match.md) | Row choice labels; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm row/answer relationships are navigable without visual table layout. |
27+
| [match-list](match-list.md) | Association controls; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm prompt/answer association context is retained during navigation. |
28+
| [math-inline](math-inline.md) | Math editor; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, math alternatives, status messages | Confirm equation editor output is usable with screen readers. |
29+
| [math-templated](math-templated.md) | Template fields; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, math alternatives, status messages | Confirm blank-to-expression context survives screen-reader navigation. |
30+
| [matrix](matrix.md) | Row/column relationships; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm row and column headers are announced for each selectable cell. |
31+
| [mc-populated-blank](mc-populated-blank.md) | Choice labels; audio/transcript; stem association | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm transcript completeness and blank/stem association. |
32+
| [multi-trait-rubric](multi-trait-rubric.md) | Trait table structure; evaluate feedback | Axe, labelled groups, control names, tab reach, status messages | Confirm trait/score descriptor relationships for assistive tech. |
33+
| [multiple-choice](multiple-choice.md) | Single-select; multi-select; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm radio vs checkbox semantics match configuration. |
34+
| [number-line](number-line.md) | Point controls; inequality rays | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm endpoint inclusion, ray direction, and plotted values are exposed textually. |
35+
| [passage](passage.md) | Reading structure; poetry order | Axe, labelled groups | Confirm line/stanza reading and hidden layout text with screen readers. |
36+
| [placement-ordering](placement-ordering.md) | Keyboard reorder; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm reorder workflow can be completed and announced without drag input. |
37+
| [rubric](rubric.md) | Score structure; descriptor order | Axe, labelled groups, control names, tab reach | Confirm descriptor reading order and heading structure in long rubrics. |
38+
| [select-text](select-text.md) | Token semantics; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm text selection workflow and selected state announcements. |
39+
| [simple-cloze](simple-cloze.md) | Input label; evaluate feedback | Axe, labelled groups, control names, tab reach, target size, status messages | Confirm input error/correctness messages are clear and associated. |
40+
| [venn-classification](venn-classification.md) | Tile/region keyboard; region overrides | Axe, labelled groups, control names, tab reach, target size, media alternatives | Confirm region placement announcements and keyboard classification workflow. |
41+
42+
## Tracking Notes
43+
44+
- Automated checks do not prove complete keyboard workflows or screen-reader usability; those are tracked as manual gaps in the per-element files.
45+
- Findings are currently non-blocking unless `A11Y_ENFORCE=1` is set.
46+
- If an issue requires changing a synced React element or shared React lib, fix it upstream before syncing into this repository.

docs/a11y/categorize.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Categorize A11y Coverage
2+
3+
## Intended Use
4+
5+
Students classify draggable answer choices into labelled categories. Evaluate mode shows correctness feedback for placed choices.
6+
7+
## Automated Coverage
8+
9+
- `category-dropzone-keyboard-names`: labelled category regions, draggable choice names, keyboard reachability, target size.
10+
- `category-feedback-status`: evaluate-mode feedback and live/status semantics.
11+
12+
## Not Covered / Manual
13+
14+
- Confirm the complete classification workflow can be completed without drag gestures.
15+
- Confirm screen readers announce pick-up, movement, drop target, and final placement changes clearly.

docs/a11y/charting.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Charting A11y Coverage
2+
3+
## Intended Use
4+
5+
Students inspect or edit chart data in bar, line, and histogram forms. The element combines form-like controls with non-text chart graphics.
6+
7+
## Automated Coverage
8+
9+
- `editable-bar-chart-controls`: labelled chart controls, keyboard reachability, target size, non-text contrast.
10+
- `line-chart-add-points`: add-point workflow, visible controls, and chart affordances.
11+
- `histogram-non-text-alternatives`: histogram graphic alternatives and color-independent chart meaning.
12+
13+
## Not Covered / Manual
14+
15+
- Confirm chart values, trends, and bin meanings are available without relying on sight or color alone.
16+
- Confirm pointer-based chart editing has a complete keyboard alternative.

docs/a11y/complex-rubric.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Complex Rubric A11y Coverage
2+
3+
## Intended Use
4+
5+
Instructors review detailed rubric criteria and score responses across long descriptors and structured scale levels.
6+
7+
## Automated Coverage
8+
9+
- `rubric-scale-structure`: rubric headings, labels, table-like relationships, and descriptor readability.
10+
- `complex-rubric-evaluate-feedback`: evaluate-mode score controls, status feedback, and labelled groups.
11+
12+
## Not Covered / Manual
13+
14+
- Confirm long descriptors are announced in a usable order by screen readers.
15+
- Confirm visual scale layout does not obscure the relationship between criteria and score points.

docs/a11y/drag-in-the-blank.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Drag In The Blank A11y Coverage
2+
3+
## Intended Use
4+
5+
Students place draggable choices into inline blanks or target areas, sometimes alongside images or math content.
6+
7+
## Automated Coverage
8+
9+
- `blank-drop-keyboard-alternative`: blank targets, draggable choice labels, keyboard reachability, and target size.
10+
- `image-blank-alternatives`: image alternatives, labelled choices, and keyboard-reachable image-based blanks.
11+
12+
## Not Covered / Manual
13+
14+
- Confirm the complete blank-filling workflow works without drag gestures.
15+
- Confirm placement changes are announced through clear live feedback.

0 commit comments

Comments
 (0)