Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9e85621
[test] Add ColorContrast a11y regression for Button
siriwatknp May 11, 2026
42c3130
[test] Add ColorContrast a11y regressions for 16 more components
siriwatknp May 11, 2026
991d653
[test] Use sx instead of flexWrap system prop in ColorContrast fixtures
siriwatknp May 11, 2026
85473dc
[test] Disable Paper overlay in Alert ColorContrast fixtures
siriwatknp May 11, 2026
1079fe9
[test] Resolve 'incomplete' axe statuses in Badge/Pagination/TextFiel…
siriwatknp May 11, 2026
9e91861
[test] Wrap Checkbox/Radio/Switch in FormControlLabel in ColorContras…
siriwatknp May 12, 2026
433c494
[test] Set color: text.primary on dark ColorContrast fixture containers
siriwatknp May 12, 2026
369c3c6
[test] Restructure ColorContrast fixtures under fixtures/ColorContrast/
siriwatknp May 12, 2026
f7125e6
[test] Split ColorContrast results back into per-component files
siriwatknp May 12, 2026
2d6dfe5
[test] Add AppBar and Typography to ColorContrast regression
siriwatknp May 12, 2026
8d08a93
[ToggleButton] Use dark/light color shade for selected state
siriwatknp May 12, 2026
c26079b
[docs-infra] Add color-contrast token experiment page
siriwatknp May 12, 2026
4fac14b
update toggle and pagination selected color
siriwatknp May 12, 2026
1f1b29d
[docs-infra] Default the color-contrast experiment to current tokens
siriwatknp May 12, 2026
63474b5
run only color-contrast
siriwatknp May 12, 2026
dc60318
[test] Add Avatar ColorContrast a11y regression (#44179)
siriwatknp May 15, 2026
9bd958e
Revert source fixes; move proposals to experiment page
siriwatknp May 15, 2026
969749b
[docs-infra] Surface ToggleButton/Pagination/Avatar fixes in color-co…
siriwatknp May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions Material UI Accessibility Gaps.md

Large diffs are not rendered by default.

965 changes: 965 additions & 0 deletions docs/pages/experiments/color-contrast-tokens.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"test:e2e:dev": "pnpm -F ./test/e2e dev",
"test:e2e-website": "playwright test test/e2e-website --config test/e2e-website/playwright.config.ts",
"test:e2e-website:dev": "cross-env PLAYWRIGHT_TEST_BASE_URL=http://localhost:3000 playwright test test/e2e-website --config test/e2e-website/playwright.config.ts",
"test:regressions": "cross-env NODE_ENV=production pnpm test:regressions:build && concurrently --success first --kill-others \"pnpm test:regressions:run\" \"pnpm test:regressions:server\" && prettier --write \"docs/data/material/components/**/*.a11y.json\"",
"test:regressions": "cross-env NODE_ENV=production pnpm test:regressions:build && concurrently --success first --kill-others \"pnpm test:regressions:run\" \"pnpm test:regressions:server\" && prettier --write \"docs/data/material/components/**/*.a11y.json\" \"test/regressions/a11y/results/*.a11y.json\"",
"test:regressions:build": "vite build test/regressions",
"test:regressions:dev": "vite test/regressions --port 5001",
"test:regressions:run": "vitest run -r ./test/regressions/",
Expand Down
171 changes: 111 additions & 60 deletions test/regressions/a11y/a11yReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import type {
Vitest,
} from 'vitest/node';
import type { SerializedError } from 'vitest';
import type { A11yMeta } from './axe';
import type { A11yMeta, RouteKind } from './axe';

const COMPONENTS_DIR = path.resolve(__dirname, '../../../docs/data/material/components');
const DOCS_COMPONENTS_DIR = path.resolve(__dirname, '../../../docs/data/material/components');
const REGRESSION_RESULTS_DIR = path.resolve(__dirname, './results');
const VRT_MODULE_PATH = path.resolve(__dirname, '../index.test.js');

interface DemoEntry {
Expand All @@ -30,15 +31,50 @@ function* walkTests(node: TestModule | TestSuite): Generator<TestCase, undefined
}
}

function hasStatus(meta: A11yMeta, status: 'fail' | 'incomplete'): boolean {
for (const rule of Object.values(meta.rules)) {
if (rule.status === status) {
return true;
}
}
return false;
function hasStatus(metas: ReadonlyArray<A11yMeta>, status: 'fail' | 'incomplete'): boolean {
return metas.some((meta) => Object.values(meta.rules).some((rule) => rule.status === status));
}

// Regression a11y fixtures live in `fixtures/ColorContrast/{Component}{Light|Dark}.js`,
// so the demo name encodes both the component (output file) and the mode
// (entry key within that file). Fixtures that don't follow the suffix
// convention fall back to a single self-named file/entry.
const REGRESSION_DEMO_RE = /^(.*?)(Light|Dark)$/;

interface OutputTarget {
/** Root dir holding the result files. */
dir: string;
/** Group key for a recorded meta — also the output file's identity. */
bucketOf: (meta: A11yMeta) => string;
/** Key for this meta's entry inside its file. */
entryKeyOf: (meta: A11yMeta) => string;
/** Absolute path of the output file for a bucket. */
fileFor: (bucket: string) => string;
/** Glob (relative to `dir`) for discovering existing result files. */
pruneGlob: string;
/** Map a discovered file path back to its bucket id. */
pruneBucketOf: (absPath: string) => string;
}

const TARGETS: Record<RouteKind, OutputTarget> = {
docs: {
dir: DOCS_COMPONENTS_DIR,
bucketOf: (meta) => meta.slug,
entryKeyOf: (meta) => meta.demo,
fileFor: (slug) => path.join(DOCS_COMPONENTS_DIR, slug, `${slug}.a11y.json`),
pruneGlob: '*/*.a11y.json',
pruneBucketOf: (file) => path.basename(path.dirname(file)),
},
regression: {
dir: REGRESSION_RESULTS_DIR,
bucketOf: (meta) => meta.demo.match(REGRESSION_DEMO_RE)?.[1] ?? meta.demo,
entryKeyOf: (meta) => meta.demo.match(REGRESSION_DEMO_RE)?.[2] ?? meta.demo,
fileFor: (component) => path.join(REGRESSION_RESULTS_DIR, `${component}.a11y.json`),
pruneGlob: '*.a11y.json',
pruneBucketOf: (file) => path.basename(file, '.a11y.json'),
},
};

export default class A11yReporter implements Reporter {
private filtered = false;

Expand All @@ -51,77 +87,92 @@ export default class A11yReporter implements Reporter {
_unhandledErrors: ReadonlyArray<SerializedError>,
reason: TestRunEndReason,
) {
const entries: A11yMeta[] = [];
// bucket id -> recorded metas, kept separate per kind
const byKind: Record<RouteKind, Map<string, A11yMeta[]>> = {
docs: new Map(),
regression: new Map(),
};
for (const mod of testModules) {
for (const test of walkTests(mod)) {
const meta = (test.meta() as { a11y?: A11yMeta }).a11y;
if (meta) {
entries.push(meta);
if (!meta) {
continue;
}
const bucket = TARGETS[meta.kind].bucketOf(meta);
const list = byKind[meta.kind].get(bucket) ?? [];
list.push(meta);
byKind[meta.kind].set(bucket, list);
}
}

const bySlug = new Map<string, A11yMeta[]>();
for (const meta of entries) {
const list = bySlug.get(meta.slug) ?? [];
list.push(meta);
bySlug.set(meta.slug, list);
}

// One file per slug, co-located with the component's other files. The docs
// toolbar reads these via a webpack require.context (eager) at build time.
for (const [slug, metas] of bySlug) {
const slugDir = path.join(COMPONENTS_DIR, slug);
fs.mkdirSync(slugDir, { recursive: true });
const sorted = [...metas].sort((a, b) => a.demo.localeCompare(b.demo));
const file: Record<string, DemoEntry> = {};
for (const meta of sorted) {
file[meta.demo] = { rules: meta.rules };
for (const kind of Object.keys(TARGETS) as RouteKind[]) {
const target = TARGETS[kind];
for (const [bucket, metas] of byKind[kind]) {
const outFile = target.fileFor(bucket);
fs.mkdirSync(path.dirname(outFile), { recursive: true });
const sorted = [...metas].sort((a, b) =>
target.entryKeyOf(a).localeCompare(target.entryKeyOf(b)),
);
const file: Record<string, DemoEntry> = {};
for (const meta of sorted) {
file[target.entryKeyOf(meta)] = { rules: meta.rules };
}
fs.writeFileSync(outFile, `${JSON.stringify(file, null, 2)}\n`);
}
fs.writeFileSync(
path.join(slugDir, `${slug}.a11y.json`),
`${JSON.stringify(file, null, 2)}\n`,
);
}

// Only prune when this run is authoritative for the full enrolment set:
// VRT module must have actually executed, no `-t` filter narrowed it, and
// the run completed cleanly. A partial/failed run can omit slugs whose
// the run completed cleanly. A partial/failed run can omit buckets whose
// tests crashed before recording, so pruning then would delete tracked
// JSON for still-enrolled demos.
const ranVrtSuite = testModules.some((m) => m.moduleId === VRT_MODULE_PATH);
if (ranVrtSuite && !this.filtered && reason === 'passed') {
for (const file of globbySync('*/*.a11y.json', { cwd: COMPONENTS_DIR, absolute: true })) {
const slug = path.basename(path.dirname(file));
if (!bySlug.has(slug)) {
fs.unlinkSync(file);
for (const kind of Object.keys(TARGETS) as RouteKind[]) {
const target = TARGETS[kind];
if (!fs.existsSync(target.dir)) {
continue;
}
const seen = byKind[kind];
for (const file of globbySync(target.pruneGlob, { cwd: target.dir, absolute: true })) {
if (!seen.has(target.pruneBucketOf(file))) {
fs.unlinkSync(file);
}
}
}
}

if (entries.length === 0) {
return;
for (const kind of Object.keys(TARGETS) as RouteKind[]) {
const buckets = byKind[kind];
if (buckets.size === 0) {
continue;
}
const names = [...buckets.keys()].sort();
const partial = names.filter((n) => hasStatus(buckets.get(n)!, 'fail'));
const needsReview = names.filter(
(n) => !partial.includes(n) && hasStatus(buckets.get(n)!, 'incomplete'),
);
const pass = names.filter((n) => !partial.includes(n) && !needsReview.includes(n));
const totalDemos = [...buckets.values()].reduce((n, ms) => n + ms.length, 0);
const target = TARGETS[kind];
const dirLabel =
kind === 'docs'
? `${path.relative(process.cwd(), target.dir)}/{slug}/{slug}.a11y.json`
: `${path.relative(process.cwd(), target.dir)}/{component}.a11y.json`;
// eslint-disable-next-line no-console
console.log(
[
'',
chalk.bold(
`a11y ${kind} results (${totalDemos} demos, ${names.length} components) -> ${dirLabel}`,
),
'',
` ✅ Pass (${pass.length}): ${pass.join(', ') || '—'}`,
` ⚠️ Partial (${partial.length}): ${partial.join(', ') || '—'}`,
` 🔍 Needs review (${needsReview.length}): ${needsReview.join(', ') || '—'}`,
'',
].join('\n'),
);
}

const slugs = [...bySlug.keys()].sort();
const partial = slugs.filter((s) => bySlug.get(s)!.some((m) => hasStatus(m, 'fail')));
const needsReview = slugs.filter(
(s) => !partial.includes(s) && bySlug.get(s)!.some((m) => hasStatus(m, 'incomplete')),
);
const pass = slugs.filter((s) => !partial.includes(s) && !needsReview.includes(s));
// eslint-disable-next-line no-console
console.log(
[
'',
chalk.bold(
`a11y results (${entries.length} demos, ${slugs.length} slugs) -> ${path.relative(process.cwd(), COMPONENTS_DIR)}/{slug}/{slug}.a11y.json`,
),
'',
` ✅ Pass (${pass.length}): ${pass.join(', ') || '—'}`,
` ⚠️ Partial (${partial.length}): ${partial.join(', ') || '—'}`,
` 🔍 Needs review (${needsReview.length}): ${needsReview.join(', ') || '—'}`,
'',
].join('\n'),
);
}
}
50 changes: 48 additions & 2 deletions test/regressions/a11y/axe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,29 @@ export const GLOBAL_DISABLED_RULES = ['region', 'page-has-heading-one'];

export type RuleStatus = 'pass' | 'fail' | 'incomplete';

/**
* One failing cell. Prop-axis keys (`variant`, `color`, etc.) are sourced
* from `data-*` attributes the fixture stamps on each cell, so this shape
* adapts to any component without code changes — Button has `variant` +
* `color`, Alert would have `severity` + `variant`, etc.
*/
export interface Instance {
contrastRatio?: number;
[prop: string]: string | number | undefined;
}

export interface RuleEntry {
status: RuleStatus;
tags: string[];
/** Populated for `fail` / `incomplete` statuses, one per offending DOM node. */
instances?: Instance[];
}

/** Which side of the suite produced the route — controls reporter output dir. */
export type RouteKind = 'docs' | 'regression';

export interface A11yMeta {
kind: RouteKind;
slug: string;
demo: string;
rules: Record<string, RuleEntry>;
Expand Down Expand Up @@ -54,6 +71,7 @@ function formatResults(results: AxeResults['violations']) {
}

interface RecordA11yOptions {
kind: RouteKind;
slug: string;
demo: string;
/**
Expand All @@ -64,6 +82,29 @@ interface RecordA11yOptions {
skipAssertions?: string[];
}

/**
* The page-side script in `index.test.js` enriches each failing node with a
* `dataAttrs` map collected by walking up the DOM from the violation node.
* Anything outside that walk doesn't reach this code.
*/
type EnrichedNode = AxeResults['violations'][number]['nodes'][number] & {
dataAttrs?: Record<string, string>;
};

function extractInstance(node: EnrichedNode): Instance {
const result: Instance = { ...(node.dataAttrs ?? {}) };
for (const check of [...node.any, ...node.all, ...node.none]) {
if (!check.data || typeof check.data !== 'object') {
continue;
}
const { contrastRatio } = check.data as { contrastRatio?: unknown };
if (typeof contrastRatio === 'number') {
result.contrastRatio = contrastRatio;
}
}
return result;
}

/**
* Node-side recorder for axe results produced inside a Playwright page.
*
Expand All @@ -74,7 +115,7 @@ interface RecordA11yOptions {
export function recordA11y(
ctx: TestContext,
results: AxeResults,
{ slug, demo, skipAssertions = [] }: RecordA11yOptions,
{ kind, slug, demo, skipAssertions = [] }: RecordA11yOptions,
): void {
const rules: Record<string, RuleEntry> = {};
const buckets: ReadonlyArray<[AxeResults['passes'], RuleStatus]> = [
Expand All @@ -85,14 +126,19 @@ export function recordA11y(
for (const [list, status] of buckets) {
for (const rule of list) {
const tags = rule.tags.filter((t) => WCAG_TAGS.includes(t)).sort();
rules[rule.id] = { status, tags };
const entry: RuleEntry = { status, tags };
if (status !== 'pass' && rule.nodes.length > 0) {
entry.instances = rule.nodes.map(extractInstance);
}
rules[rule.id] = entry;
}
}
const sortedRules = Object.fromEntries(
Object.entries(rules).sort(([a], [b]) => a.localeCompare(b)),
);

const meta: A11yMeta = {
kind,
slug,
demo,
rules: sortedRules,
Expand Down
30 changes: 30 additions & 0 deletions test/regressions/a11y/results/Alert.a11y.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"Dark": {
"rules": {
"color-contrast": {
"status": "pass",
"tags": ["wcag2aa"]
}
}
},
"Light": {
"rules": {
"color-contrast": {
"status": "fail",
"tags": ["wcag2aa"],
"instances": [
{
"variant": "filled",
"severity": "info",
"contrastRatio": 3.85
},
{
"variant": "filled",
"severity": "warning",
"contrastRatio": 3.11
}
]
}
}
}
}
34 changes: 34 additions & 0 deletions test/regressions/a11y/results/AppBar.a11y.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"Dark": {
"rules": {
"color-contrast": {
"status": "fail",
"tags": ["wcag2aa"],
"instances": [
{
"color": "error",
"contrastRatio": 3.68
}
]
}
}
},
"Light": {
"rules": {
"color-contrast": {
"status": "fail",
"tags": ["wcag2aa"],
"instances": [
{
"color": "info",
"contrastRatio": 3.85
},
{
"color": "warning",
"contrastRatio": 3.11
}
]
}
}
}
}
Loading
Loading