diff --git a/AGENTS.md b/AGENTS.md
index c88dd59c25d286..3f5ab7bb60f0df 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -166,7 +166,7 @@ axe-core runs inside the visual-regression Playwright loop (`test/regressions/in
Key files:
- `test/regressions/demoMeta.ts` — `SCREENSHOT_RULES` and `A11Y_RULES` arrays, matched last-wins (no inheritance: overrides restate every field) against `docs/data/material/components/{slug}/{Demo}` (minimatch globs).
-- `test/regressions/a11y/axe.ts` — asserts `color-contrast` and `link-in-text-block` unless listed in `skipAssertions`.
+- `test/regressions/a11y/axe.ts` — asserts `color-contrast` and `link-in-text-block` by default, or all exercised axe rules when the matching a11y rule sets `assertions: 'all'`; `skipAssertions` suppresses selected rule assertions.
- `test/regressions/a11y/a11yReporter.ts` — writes one file per slug at `docs/data/material/components/{slug}/{slug}.a11y.json`. Each file is keyed by demo name, then by axe rule ID. Each rule records a `status` (`pass`, `fail`, or `incomplete`) and WCAG tags.
Enroll a component (slug-wide, or narrow with brace-glob):
@@ -174,7 +174,7 @@ Enroll a component (slug-wide, or narrow with brace-glob):
```ts
// test/regressions/demoMeta.ts
{ test: 'docs/data/material/components/alert/*', enabled: true, skipAssertions: ['color-contrast'] },
-{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true },
+{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true, assertions: 'all' },
```
Override a specific demo: append a per-demo rule _after_ the slug-wide rule (last-match-wins; the override must restate every field it wants):
diff --git a/docs/data/material/components/buttons/ButtonA11yColorMatrix.js b/docs/data/material/components/buttons/ButtonA11yColorMatrix.js
new file mode 100644
index 00000000000000..4f4a2fa001b8cb
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yColorMatrix.js
@@ -0,0 +1,40 @@
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+
+const variants = ['text', 'outlined', 'contained'];
+const colors = [
+ 'primary',
+ 'secondary',
+ 'success',
+ 'error',
+ 'info',
+ 'warning',
+ 'inherit',
+];
+
+export default function ButtonA11yColorMatrix() {
+ return (
+ div': {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: 1,
+ },
+ }}
+ >
+ {variants.map((variant) => (
+
+ {colors.map((color) => (
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx b/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx
new file mode 100644
index 00000000000000..af7b1d5715d212
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx
@@ -0,0 +1,40 @@
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+
+const variants = ['text', 'outlined', 'contained'] as const;
+const colors = [
+ 'primary',
+ 'secondary',
+ 'success',
+ 'error',
+ 'info',
+ 'warning',
+ 'inherit',
+] as const;
+
+export default function ButtonA11yColorMatrix() {
+ return (
+ div': {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: 1,
+ },
+ }}
+ >
+ {variants.map((variant) => (
+
+ {colors.map((color) => (
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview b/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview
new file mode 100644
index 00000000000000..93bb23732292e4
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview
@@ -0,0 +1,9 @@
+{variants.map((variant) => (
+
+ {colors.map((color) => (
+
+ ))}
+
+))}
\ No newline at end of file
diff --git a/docs/data/material/components/buttons/ButtonA11yNonNative.js b/docs/data/material/components/buttons/ButtonA11yNonNative.js
new file mode 100644
index 00000000000000..e27cb3960763a8
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yNonNative.js
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import Stack from '@mui/material/Stack';
+import Button from '@mui/material/Button';
+
+const CustomDivButton = React.forwardRef(function CustomDivButton(props, ref) {
+ return ;
+});
+
+export default function ButtonA11yNonNative() {
+ return (
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/buttons/ButtonA11yNonNative.tsx b/docs/data/material/components/buttons/ButtonA11yNonNative.tsx
new file mode 100644
index 00000000000000..225a0d0e68d22b
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yNonNative.tsx
@@ -0,0 +1,23 @@
+import * as React from 'react';
+import Stack from '@mui/material/Stack';
+import Button from '@mui/material/Button';
+
+const CustomDivButton = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(function CustomDivButton(props, ref) {
+ return ;
+});
+
+export default function ButtonA11yNonNative() {
+ return (
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview b/docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview
new file mode 100644
index 00000000000000..ea5874fce148de
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/docs/data/material/components/buttons/ButtonA11ySemanticStates.js b/docs/data/material/components/buttons/ButtonA11ySemanticStates.js
new file mode 100644
index 00000000000000..f4c5156ef19827
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11ySemanticStates.js
@@ -0,0 +1,58 @@
+import { styled } from '@mui/material/styles';
+import Button from '@mui/material/Button';
+import Stack from '@mui/material/Stack';
+import CloudUploadIcon from '@mui/icons-material/CloudUpload';
+import DeleteIcon from '@mui/icons-material/Delete';
+import SaveIcon from '@mui/icons-material/Save';
+import SendIcon from '@mui/icons-material/Send';
+
+const VisuallyHiddenInput = styled('input')({
+ clip: 'rect(0 0 0 0)',
+ clipPath: 'inset(50%)',
+ height: 1,
+ overflow: 'hidden',
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ whiteSpace: 'nowrap',
+ width: 1,
+});
+
+export default function ButtonA11ySemanticStates() {
+ return (
+
+
+
+
+
+
+
+ }>
+ Delete file
+
+ }>
+ Send message
+
+ }
+ variant="outlined"
+ >
+ Save changes
+
+
+ }
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ Upload receipt
+
+
+
+ );
+}
diff --git a/docs/data/material/components/buttons/ButtonA11ySemanticStates.tsx b/docs/data/material/components/buttons/ButtonA11ySemanticStates.tsx
new file mode 100644
index 00000000000000..f4c5156ef19827
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11ySemanticStates.tsx
@@ -0,0 +1,58 @@
+import { styled } from '@mui/material/styles';
+import Button from '@mui/material/Button';
+import Stack from '@mui/material/Stack';
+import CloudUploadIcon from '@mui/icons-material/CloudUpload';
+import DeleteIcon from '@mui/icons-material/Delete';
+import SaveIcon from '@mui/icons-material/Save';
+import SendIcon from '@mui/icons-material/Send';
+
+const VisuallyHiddenInput = styled('input')({
+ clip: 'rect(0 0 0 0)',
+ clipPath: 'inset(50%)',
+ height: 1,
+ overflow: 'hidden',
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ whiteSpace: 'nowrap',
+ width: 1,
+});
+
+export default function ButtonA11ySemanticStates() {
+ return (
+
+
+
+
+
+
+
+ }>
+ Delete file
+
+ }>
+ Send message
+
+ }
+ variant="outlined"
+ >
+ Save changes
+
+
+ }
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ Upload receipt
+
+
+
+ );
+}
diff --git a/docs/data/material/components/buttons/ButtonA11yTextSpacing.js b/docs/data/material/components/buttons/ButtonA11yTextSpacing.js
new file mode 100644
index 00000000000000..3bdcf3f0c54528
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yTextSpacing.js
@@ -0,0 +1,40 @@
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Stack from '@mui/material/Stack';
+import SaveIcon from '@mui/icons-material/Save';
+import SendIcon from '@mui/icons-material/Send';
+
+export default function ButtonA11yTextSpacing() {
+ return (
+
+
+
+ }
+ sx={{ whiteSpace: 'normal' }}
+ >
+ Save a longer action label
+
+ }
+ sx={{ whiteSpace: 'normal' }}
+ >
+ Send confirmation message
+
+
+
+ );
+}
diff --git a/docs/data/material/components/buttons/ButtonA11yTextSpacing.tsx b/docs/data/material/components/buttons/ButtonA11yTextSpacing.tsx
new file mode 100644
index 00000000000000..3bdcf3f0c54528
--- /dev/null
+++ b/docs/data/material/components/buttons/ButtonA11yTextSpacing.tsx
@@ -0,0 +1,40 @@
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Stack from '@mui/material/Stack';
+import SaveIcon from '@mui/icons-material/Save';
+import SendIcon from '@mui/icons-material/Send';
+
+export default function ButtonA11yTextSpacing() {
+ return (
+
+
+
+ }
+ sx={{ whiteSpace: 'normal' }}
+ >
+ Save a longer action label
+
+ }
+ sx={{ whiteSpace: 'normal' }}
+ >
+ Send confirmation message
+
+
+
+ );
+}
diff --git a/docs/data/material/components/buttons/buttons.a11y.json b/docs/data/material/components/buttons/buttons.a11y.json
index c440f41f0f264e..d2dfd8e0bcf3ac 100644
--- a/docs/data/material/components/buttons/buttons.a11y.json
+++ b/docs/data/material/components/buttons/buttons.a11y.json
@@ -39,6 +39,262 @@
}
}
},
+ "ButtonA11yColorMatrix": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "fail",
+ "tags": ["wcag2aa"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "ButtonA11yNonNative": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-command-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-deprecated-role": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-required-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-roles": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "ButtonA11ySemanticStates": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-deprecated-role": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-hidden-focus": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-progressbar-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-required-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-roles": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "avoid-inline-spacing": {
+ "status": "pass",
+ "tags": ["wcag21aa"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "duplicate-id-aria": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "form-field-multiple-labels": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "label": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "link-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "ButtonA11yTextSpacing": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-hidden-focus": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "ButtonSizes": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
"ColorButtons": {
"rules": {
"aria-allowed-attr": {
@@ -78,5 +334,365 @@
"tags": ["wcag22aa"]
}
}
+ },
+ "ContainedButtons": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "link-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "CustomizedButtons": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "DisableElevation": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "IconLabelButtons": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-hidden-focus": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "InputFileUpload": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-hidden-focus": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "form-field-multiple-labels": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "label": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "LoadingButtons": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-deprecated-role": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-hidden-focus": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-progressbar-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-required-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-roles": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "avoid-inline-spacing": {
+ "status": "pass",
+ "tags": ["wcag21aa"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "duplicate-id-aria": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ }
+ }
+ },
+ "OutlinedButtons": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "link-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "TextButtons": {
+ "rules": {
+ "aria-allowed-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-conditional-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-prohibited-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "aria-valid-attr-value": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "link-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
}
}
diff --git a/packages/mui-material/src/Button/accessibility.md b/packages/mui-material/src/Button/accessibility.md
new file mode 100644
index 00000000000000..37ad661454c749
--- /dev/null
+++ b/packages/mui-material/src/Button/accessibility.md
@@ -0,0 +1,400 @@
+# Button accessibility conformance
+
+Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility.md).
+
+| Result | Count |
+| :-------------------- | :---- |
+| ✅ Supports | 23 |
+| ⚠️ Partially Supports | 4 |
+| ❌ Does Not Support | 0 |
+| ➖ Not Applicable | 28 |
+| 🚩 Unverified | 17/27 |
+
+## Known gaps
+
+- ⚠️ **1.4.3 Contrast (Minimum).** `info` and `warning` contained buttons fall short of 4.5:1.
+- ⚠️ **1.4.11 Non-text Contrast.** Focus-indicator, border, and icon contrast are untested; `disableRipple`/`disableFocusRipple` remove the `text`/`outlined` focus indicator, and `disableElevation` removes the `contained` one.
+- ⚠️ **2.4.7 Focus Visible.** `disableRipple`/`disableFocusRipple` remove the `text`/`outlined` focus indicator; `contained` loses its indicator only when `disableElevation` is combined with them.
+- ⚠️ **4.1.3 Status Messages.** The `loading` state adds no live region, so the change may go unannounced.
+
+## Success criteria
+
+### 🔍 Manual
+
+#### 1.3.2 Meaningful Sequence · A
+
+`🚩 Unverified` · `✅ Supports` · `○ Author`
+
+- The button is one control. Its slots render in DOM order (start icon, label, end icon); decorative MUI icons are `aria-hidden`, leaving the single label as the exposed content. Custom icon nodes are not hidden automatically.
+- Order carries meaning only across several controls, which the surrounding layout sets. Confirm that reading order matches the visual order of any button group.
+
+**Manual testing steps**
+
+1. Open a demo with a row of buttons (`BasicButtons`).
+2. Press Tab repeatedly and note the order the buttons receive focus.
+3. Compare that order to the visual left-to-right order.
+
+**Pass:** focus order matches the visual order. Watch for `Stack` or flex layouts that reorder buttons visually without changing the DOM.
+
+#### 1.3.3 Sensory Characteristics · A
+
+`🚩 Unverified` · `✅ Supports` · `○ Author`
+
+- A button with text or an `aria-label` can be identified by name, not only by shape, color, or position.
+- Instructions in the surrounding content must not rely on color, shape, or position alone (for example, "press the green button").
+
+**Manual testing steps**
+
+1. Find any product copy that tells users to operate a button.
+2. Check that it names the button by its label, not only by color, shape, size, or position.
+
+**Pass:** no instruction relies on "the green button" or "the button on the right" without naming it.
+
+#### 1.4.4 Resize Text · AA
+
+`🚩 Unverified` · `✅ Supports` · `◐ Shared`
+
+- Typography is set in rem and em, so the label and button scale with browser zoom or font size rather than staying pixel-fixed.
+- A fixed-pixel container in the surrounding layout could clip at 200%.
+
+**Manual testing steps**
+
+1. Open the Button demos and set browser zoom to 200% (Ctrl or Cmd and +).
+2. Confirm labels are fully visible and buttons still work.
+
+**Pass:** nothing is clipped or cut off at 200%.
+
+#### 1.4.5 Images of Text · AA
+
+`✅ Supports` · `○ Author`
+
+- The label is real text.
+- A logo can be a valid exception, as long as the Button has a proper accessible name.
+
+**Manual testing steps**
+
+1. Open any button demo and confirm the label is selectable text, not an image.
+
+**Pass:** no button renders its label as an image of text (a logo with an accessible name is exempt).
+
+#### 1.4.10 Reflow · AA
+
+`🚩 Unverified` · `✅ Supports` · `◐ Shared`
+
+- Labels wrap by default (the component sets no `white-space`), so a button reflows on its own. Horizontal overflow at 320 CSS pixels comes from the surrounding layout, such as a fixed-width button or a non-wrapping row.
+
+**Manual testing steps**
+
+1. Open the Button demos and set the window, or the DevTools device toolbar, to 320 CSS pixels wide.
+2. Confirm there is no sideways scrolling and all button text is reachable.
+
+**Pass:** content reflows with no horizontal scroll, and long labels wrap.
+
+#### 2.4.11 Focus Not Obscured (Minimum) · AA
+
+`🚩 Unverified` · `✅ Supports` · `○ Author`
+
+- The button is an ordinary focusable element and never places itself behind other content. Obscuring comes from sticky headers, banners, or overlays in the surrounding layout.
+- Confirm by moving focus to the button beneath any sticky or overlay content at several scroll positions. At least part of it must stay visible.
+
+**Manual testing steps**
+
+1. In a page that has a sticky header, footer, or banner, press Tab to move focus onto a button near it.
+2. Scroll so the button sits under the sticky element, then Tab to it again.
+
+**Pass:** at least part of the focused button stays visible, never fully covered.
+
+#### 3.2.4 Consistent Identification · AA
+
+`🚩 Unverified` · `✅ Supports` · `○ Author`
+
+- The component produces one stable accessible name per set of props, the precondition for consistent identification.
+- Consistency is a cross-page property. Confirm that buttons with the same function share a label and icon, and that one label is not reused for different functions.
+
+**Manual testing steps**
+
+1. List the buttons that do the same job across the product (for example, every "Delete").
+2. Compare their labels and icons.
+
+**Pass:** the same job uses the same label and icon, and no single label is reused for different jobs.
+
+#### 4.1.3 Status Messages · AA
+
+`🚩 Unverified` · `⚠️ Partially Supports` · `◐ Shared`
+
+- The `loading` state renders a `CircularProgress` with `role="progressbar"` named by the button, but the component adds no `aria-live` or `role="status"` region, so the change may go unannounced when focus is elsewhere.
+- This applies only when `loading` is toggled for a background task. Add a live region in the surrounding application if the result must be announced.
+
+**Manual testing steps**
+
+1. Open `LoadingButtonsTransition` with a screen reader running (NVDA with Chrome, or VoiceOver with Safari).
+2. The demo starts in the loading state; toggle the switch off to enable the buttons, then click one to start loading. Press Tab to move focus away from it.
+3. Listen for whether the loading change is announced.
+
+**Pass:** the change is announced without the user moving focus to it. It is not, because the component adds no live region; wrap the status in `role="status"` to fix it.
+
+### 🔁 Hybrid
+
+#### 1.1.1 Non-text Content · A
+
+`✅ Supports` · `◐ Shared`
+
+- `SvgIcon` is used as `startIcon`/`endIcon` which default to `aria-hidden` and are not focusable, so they do not affect the name.
+- Custom icon nodes must be hidden by the author if decorative. axe-core `button-name`, `link-name`, and `aria-command-name` confirm a name is present across the demos.
+- The name comes from the children or `aria-label`; an icon-only button with no label has none. Whether the name conveys meaning needs an assistive-technology review.
+
+**Manual testing steps**
+
+1. Open `ButtonA11ySemanticStates` with a screen reader running (NVDA with Chrome, or VoiceOver with Safari).
+2. Tab to each button and confirm it announces the visible label, and does not read out the icon.
+3. For any icon-only button in the product, confirm it has an `aria-label` describing the action.
+
+**Pass:** every button's announced name matches its purpose, and decorative icons are silent.
+
+#### 1.3.1 Info and Relationships · A
+
+`✅ Supports` · `◐ Shared`
+
+- The component sets the correct role (`button`, `link` with `href`, or `role="button"` for non-native), the disabled and loading state, and the upload label-to-input relationship. axe-core's ARIA rules pass across the demos.
+- Whether the runtime loading or disabled state change is announced needs an assistive-technology review; static axe-core does not cover it.
+
+**Manual testing steps**
+
+1. Open `ButtonA11ySemanticStates` and `ButtonA11yNonNative` with a screen reader running.
+2. Confirm the plain button announces "button", the `href` one announces "link", the disabled one announces its disabled state, and the custom one announces "button".
+
+**Pass:** role and state match the visual presentation for every variant.
+
+#### 1.4.1 Use of Color · A
+
+`🚩 Unverified` · `✅ Supports` · `◐ Shared`
+
+- A button's purpose comes from its label or icon, and focus, disabled, and loading are shown without relying on color, so the component does not use color alone.
+- Two buttons can still be distinguished by palette alone (for example, `error` against `success`). Confirm in grayscale that meaning survives.
+
+**Manual testing steps**
+
+1. Open `ColorButtons` or `ButtonA11yColorMatrix`.
+2. Turn on a grayscale view (in Chrome DevTools: Rendering tab, Emulate vision deficiencies, Achromatopsia).
+3. Confirm each button can still be told apart and understood from its text.
+
+**Pass:** meaning/purpose of the Button can be understood without color.
+
+#### 1.4.3 Contrast (Minimum) · AA
+
+`⚠️ Partially Supports` · `● Component`
+
+- `info` and `warning` contained buttons do not meet `4.5:1`.
+- axe-core `color-contrast` checks the default state.
+- `:hover` and `:active` colors need a visual check.
+- Disabled buttons are exempt.
+
+**Manual testing steps**
+
+1. Open `ButtonA11yColorMatrix`. With a contrast checker (the color picker in browser DevTools shows a ratio), check each label against its background.
+2. Repeat with the pointer hovering and held down, since `:hover` and `:active` change the background.
+3. Check any custom theme colors the product uses.
+
+**Pass:** at least `4.5:1`, or `3:1` for large text, in the resting, hover, and active states. Disabled is exempt; `info` and `warning` are the known failures.
+
+#### 1.4.11 Non-text Contrast · AA
+
+`🚩 Unverified` · `⚠️ Partially Supports` · `● Component`
+
+- The focus indicator, the `outlined` border, and any meaningful icon each need `3:1` against adjacent colors.
+- The indicator is the ripple for `text`/`outlined` and a box-shadow for `contained`; `disableRipple`/`disableFocusRipple` remove the ripple and `disableElevation` the box-shadow, so it can be absent entirely.
+- Disabled buttons are exempt.
+
+**Manual testing steps**
+
+1. Open `OutlinedButtons`. With a contrast checker, measure the button border against the page behind it.
+2. Press Tab to a button so its focus indicator shows, and measure the indicator against the colors next to it.
+3. If a button has a meaningful icon, measure it against its background.
+
+**Pass:** border, focus indicator, and any meaningful icon are each at least `3:1`. Disabled and purely decorative parts are exempt.
+
+#### 1.4.12 Text Spacing · AA
+
+`🚩 Unverified` · `✅ Supports` · `◐ Shared`
+
+- Labels wrap and the button height comes from padding, not a fixed height, so the WCAG text-spacing values grow the button without clipping.
+
+**Manual testing steps**
+
+1. Open `ButtonA11yTextSpacing` and a button with a long label.
+2. Apply the WCAG text-spacing values. The quickest way is to run this in the DevTools console: `document.head.insertAdjacentHTML('beforeend','')`.
+3. Look for cut-off, clipped, or overlapping label text.
+
+**Pass:** all label text stays visible and the button still works.
+
+#### 2.4.4 Link Purpose (In Context) · A
+
+`✅ Supports` · `◐ Shared`
+
+- This applies only with `href`, where the root is a ``. axe-core `link-name` confirms a non-empty accessible name on the link demos.
+- Whether the name and context convey the destination is an authoring concern.
+
+**Manual testing steps**
+
+1. Find buttons that use `href` (they render as links).
+2. Read each link's label together with the text around it.
+
+**Pass:** a user can tell where the link goes. Replace vague labels like "Learn more" or "click here".
+
+#### 2.4.6 Headings and Labels · AA
+
+`✅ Supports` · `◐ Shared`
+
+- The accessible name serves as the control's label. axe-core `button-name` and `link-name` confirm it is present.
+- Whether the label describes the action ("Submit order" against a vague name) is an authoring concern.
+
+**Manual testing steps**
+
+1. Read each button's label out of context.
+2. Ask whether it says what the button does.
+
+**Pass:** every label describes its action ("Submit order", not "OK").
+
+#### 2.4.7 Focus Visible · AA
+
+`🚩 Unverified` · `⚠️ Partially Supports` · `● Component`
+
+- Keyboard focus shows the `.Mui-focusVisible` indicator (suppressed for mouse); `contained` adds a box-shadow on focus.
+- `disableRipple`/`disableFocusRipple` remove the ripple and `disableElevation` the `contained` box-shadow, so `text`/`outlined` lose the indicator with either ripple prop, and `contained` only when a ripple prop and `disableElevation` are both set.
+
+**Manual testing steps**
+
+1. Open `ContainedButtons`, `OutlinedButtons`, and `TextButtons`. Press Tab to move to each button.
+2. Confirm a clear focus indicator appears, and that it looks different from the hover style.
+3. Click a button with the mouse and confirm the indicator does not appear (it is keyboard-only).
+4. Tab to `text` and `outlined` buttons that set `disableRipple` or `disableFocusRipple`; for `contained`, test a button that combines one of those props with `disableElevation`.
+
+**Pass:** every keyboard-focused button shows a visible indicator, including under `disableRipple`, `disableFocusRipple`, and `disableElevation`.
+
+#### 2.5.3 Label in Name · A
+
+`🚩 Unverified` · `✅ Supports` · `◐ Shared`
+
+- The visible text is the accessible name: the children become the name, decorative MUI icons are hidden (a custom icon node is not, unless the author hides it), and `loadingPosition="center"` keeps the label in the name despite `color: transparent`.
+- An `aria-label` that omits or reorders the visible words breaks this. Compare the visible text to the computed name.
+
+**Manual testing steps**
+
+1. Open the accessibility tree (in Chrome DevTools: Elements panel, Accessibility tab) and select a button.
+2. Compare its computed Name to the words shown on screen.
+3. Optional: with Voice Control (macOS) or Dragon, say "click [label]" and confirm it activates.
+
+**Pass:** the visible text appears in the accessible name. An `aria-label` that drops or reorders the visible words fails.
+
+#### 3.3.2 Labels or Instructions · A
+
+`✅ Supports` · `◐ Shared`
+
+- This applies to the file-upload pattern (`component="label"` wrapping a hidden ``), where the button text labels a real input. axe-core `label` and `form-field-multiple-labels` pass on that demo.
+- Whether the label text is sufficiently instructive is a manual review. A plain action button is outside this criterion.
+
+**Manual testing steps**
+
+1. Open `InputFileUpload` and read the upload button's label.
+
+**Pass:** the label clearly tells the user what to upload, not just "Upload".
+
+#### 4.1.2 Name, Role, Value · A
+
+`✅ Supports` · `◐ Shared`
+
+- Native button, anchor, and `role="button"` set the correct role; `disabled` and `loading` set state; the loading progressbar is named by the button. axe-core `button-name`, the `aria-*` rules, `nested-interactive`, and `duplicate-id-aria` all pass.
+- axe-core covers the mechanical layer. Whether the name is meaningful, the role matches intent, and the runtime state change is announced needs an assistive-technology review.
+
+**Manual testing steps**
+
+1. Open `ButtonA11ySemanticStates`, `ButtonA11yNonNative`, and `LoadingButtons` with a screen reader running.
+2. Tab to each variant and confirm the announced name, role, and disabled state are correct.
+3. Toggle loading and confirm the busy or disabled change is announced.
+
+**Pass:** name, role, and state are correct for every variant, and state changes are announced.
+
+### ⚙️ Automated
+
+#### 2.1.1 Keyboard · A
+
+`✅ Supports` · `● Component`
+
+- Native buttons and non-native `role="button"` activate with Enter and Space; an `href` anchor uses native link behavior (Enter activates, Space does not). Disabled buttons leave the tab order.
+- Confirmed by interaction tests in [`../ButtonBase/ButtonBase.test.js`](../ButtonBase/ButtonBase.test.js) (Enter and Space activation, disabled non-native cases).
+
+#### 2.1.2 No Keyboard Trap · A
+
+`🚩 Unverified` · `✅ Supports` · `● Component`
+
+- A single focusable control that installs no focus-capturing loop. Tab moves in and out, and a disabled button leaves the tab order (the `disabled` attribute on native buttons, `tabIndex=-1` on non-native).
+
+#### 2.4.3 Focus Order · A
+
+`🚩 Unverified` · `✅ Supports` · `◐ Shared`
+
+- The component sits in natural DOM order with no positive `tabIndex`, and disabled or loading buttons leave the order, so it is one correct focus stop.
+- Order across controls is the surrounding layout's responsibility.
+
+#### 2.5.2 Pointer Cancellation · A
+
+`🚩 Unverified` · `✅ Supports` · `● Component`
+
+- Activation runs on `click`, fired on pointer-up over the target. `onMouseDown` only starts the ripple, and releasing off the target cancels, so nothing runs on the down event.
+
+#### 2.5.8 Target Size (Minimum) · AA
+
+`✅ Supports` · `● Component`
+
+- Default sizes meet the 24 by 24 CSS pixel minimum (medium is about 36 pixels tall). axe-core `target-size` confirms this across the Button demos in [`buttons.a11y.json`](../../../../docs/data/material/components/buttons/buttons.a11y.json).
+- Not covered: `sx` or `size` overrides that shrink a custom button, or hit-area changes under browser zoom.
+
+#### 3.2.1 On Focus · A
+
+`🚩 Unverified` · `✅ Supports` · `● Component`
+
+- Focus triggers only the focus-visible ripple and `onFocus` callbacks. There is no navigation, dialog, or focus move, so focus alone changes no context.
+
+#### 3.2.2 On Input · A
+
+`🚩 Unverified` · `✅ Supports` · `◐ Shared`
+
+- Toggling the button's setting (`loading` to disabled, or an `aria-pressed` toggle) changes no context on its own.
+- Whether an author's handler couples that change to navigation or a new window without warning is an author decision.
+
+## Not applicable
+
+- **1.3.5 Identify Input Purpose (AA).** Not an input.
+- **1.4.13 Content on Hover or Focus (AA).**
+- **2.1.4 Character Key Shortcuts (A).** The only keys are Enter and Space which are not shortcuts.
+- **2.2.2 Pause, Stop, Hide (A).** The loading spinner is user-triggered, and the button is disabled while loading.
+- **2.5.7 Dragging Movements (AA, new in 2.2).** No drag interactions.
+- **3.1.1 Language of Page (A), 3.1.2 Language of Parts (AA).** Authoring concern.
+- **3.2.3 Consistent Navigation (AA).** Inapplicable in isolation.
+- **3.2.6 Consistent Help (A, new in 2.2).** Inapplicable in isolation.
+- **3.3.7 Redundant Entry (A, new in 2.2).** Covers repopulating previously entered data. The button captures none.
+- **3.3.8 Accessible Authentication (Minimum) (AA, new in 2.2).** The paste, autofill, and cognitive-test duty falls on the credential fields, not the submit button.
+- **Time-based media (1.2.1 to 1.2.5).** No audio or video.
+- **Audio Control (1.4.2).** Emits no audio.
+- **Orientation (1.3.4).** Sets no orientation lock; a layout concern.
+- **Bypass Blocks (2.4.1), Page Titled (2.4.2), Multiple Ways (2.4.5).** Page or site structure concerns.
+- **Timing Adjustable (2.2.1).** Sets no time limit.
+- **Three Flashes or Below Threshold (2.3.1).** Nothing flashes (one ripple of about 550 ms).
+- **Pointer Gestures (2.5.1), Motion Actuation (2.5.4).** Activates on a simple click; reads no device motion.
+- **Error Identification (3.3.1), Error Suggestion (3.3.3), Error Prevention (3.3.4).** The button collects and validates no input; these belong to the form or process.
+
+## Level AAA
+
+- **2.3.3 Animation from Interactions.** The ripple's `scale()` animation honors `prefers-reduced-motion` when the theme sets `motion.reducedMotion` to `system` (follows the OS) or `always`; `disableRipple` also removes it. The default is `never`, so OS reduced-motion is not honored by default. `🚩`
+- **2.4.13 Focus Appearance.** The focus indicator is unlikely to meet the area and 3:1 thresholds, especially with `disableRipple`. `🚩`
+- **2.5.5 Target Size (Enhanced), 44 px.** Default sizes (about 36 px) are below 44 px. `🚩`
+- **1.4.6 Contrast (Enhanced), 7:1.** Palettes target the AA 4.5:1, so several combinations fall short of 7:1. `🚩`
+- Also touched, in the same shape as their A and AA siblings: **1.3.6 Identify Purpose, 1.4.9 Images of Text (No Exception), 2.1.3 Keyboard (No Exception), 2.4.9 Link Purpose (Link Only), 2.4.12 Focus Not Obscured (Enhanced)**.
+
+## Scope and test environment
+
+- **Standard.** WCAG 2.2, Level A and AA.
+- **Component version.** `@mui/material` 9.1.1.
+- **Scope.** The Button component in isolation, rendered through its documented API.
+- **Automated.** axe-core via Playwright test harness (results in [`buttons.a11y.json`](../../../../docs/data/material/components/buttons/buttons.a11y.json)), plus interaction tests in `ButtonBase.test.js` and `Button.test.js`.
+- **Assistive-technology review.** Not yet performed. `🚩` criteria are assessed from source pending a review with NVDA, JAWS, and VoiceOver.
diff --git a/packages/mui-material/src/accessibility.md b/packages/mui-material/src/accessibility.md
new file mode 100644
index 00000000000000..ef711c5a9e16e2
--- /dev/null
+++ b/packages/mui-material/src/accessibility.md
@@ -0,0 +1,76 @@
+# Accessibility conformance reports
+
+Each component is rated against WCAG 2.2 Level A and AA and documented at `/accessibility.md`.
+
+## The status line
+
+For each SC this indicates:
+
+1. How well it conforms
+2. Whether the library or user (the author) is responsible for conformance
+
+```text
+ ·
+```
+
+For example this:
+
+```text
+⚠️ Partially Supports · ● Component
+```
+
+Means:
+
+1. Partially conforms
+2. The component is fully responsible for WCAG conformance
+
+## Conformance
+
+Whether the component meets the applicable Success Criterion. [VPAT](https://www.itic.org/policy/accessibility/vpat) terminology is used:
+
+| Symbol | Term | Description |
+| :----- | :----------------- | :---------------------------------------------- |
+| ✅ | Supports | Met, no known defects. |
+| ⚠️ | Partially Supports | Some functionality fails. |
+| ❌ | Does Not Support | Most functionality fails. |
+| ➖ | Not Applicable | The criterion does not apply to this component. |
+
+`🚩 Unverified` prefixes a rating that is assessed from the component's source but not yet confirmed by a test or recorded review. It not imply a defect.
+
+## Responsibility
+
+Whether the responsibility for meeting conformance is on the library, the author (library user), or shared.
+
+| Symbol | Term | Description |
+| :----- | :-------- | :---------------------------------------------------------- |
+| ● | Component | Satisfied on its own. |
+| ◐ | Shared | Satisfied when the component is used as documented. |
+| ○ | Author | Depends on your implementation and the surrounding content. |
+
+## Testing-method groups
+
+Criteria are grouped by testing method, and roughly sorted by descending order of "human judgement required".
+
+| Symbol | Group | What it takes |
+| :----- | :-------- | :-------------------------------------------------------------------------------------- |
+| 🔍 | Manual | Human, visual, or assistive-technology judgment. |
+| 🔁 | Hybrid | Automation catches regressions; judgment still needed. |
+| ⚙️ | Automated | A deterministic test proves it. `🚩` means such a test is feasible but not yet written. |
+
+## Scope
+
+Components are rated in isolation against WCAG 2.2 A and AA. The levels are [cumulative](https://www.w3.org/WAI/WCAG2AA-Conformance), that is, AA includes all of A.
+
+## Reports
+
+| Component | ✅ Supports | ⚠️ Partially Supports | ❌ Does Not Support | ➖ Not Applicable |
+| :---------------------------------- | :---------- | :-------------------- | :------------------ | :---------------- |
+| Avatar | | | | |
+| [Button](./Button/accessibility.md) | 23 | 4 | 0 | 28 |
+| Checkbox | | | | |
+| LinearProgress | | | | |
+| Radio | | | | |
+| RadioGroup | | | | |
+| Switch | | | | |
+| ToggleButton | | | | |
+| ToggleButtonGroup | | | | |
diff --git a/test/regressions/README.md b/test/regressions/README.md
index 7f450a12a9fa2d..990ba21f844655 100644
--- a/test/regressions/README.md
+++ b/test/regressions/README.md
@@ -52,11 +52,13 @@ A fixture can be loaded with `await renderFixture(fixturePath)`, for example `re
Accessibility checks are opt-in.
Add rules in `./demoMeta.ts` under `A11Y_RULES`.
+By default, only CSS-dependent visual axe rules are asserted.
+Set `assertions: 'all'` when a fixture is expected to pass every axe rule it exercises.
Use a slug-wide rule for many demos, or a brace-glob for specific demos:
```ts
-{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true }
+{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true, assertions: 'all' }
```
Filtered runs with `-t` only refresh matched slugs.
diff --git a/test/regressions/a11y/axe.ts b/test/regressions/a11y/axe.ts
index 35ff72b0a8bf08..7581807f8298c9 100644
--- a/test/regressions/a11y/axe.ts
+++ b/test/regressions/a11y/axe.ts
@@ -56,6 +56,11 @@ function formatResults(results: AxeResults['violations']) {
interface RecordA11yOptions {
slug: string;
demo: string;
+ /**
+ * `visual` asserts only rules that need real rendered CSS. `all` asserts
+ * every axe violation/incomplete not listed in `skipAssertions`.
+ */
+ assertions?: 'visual' | 'all';
/**
* Rule ids whose violations are recorded but not asserted on. The rule
* still runs and still lands in the results JSON — only the test-failing
@@ -74,7 +79,7 @@ interface RecordA11yOptions {
export function recordA11y(
ctx: TestContext,
results: AxeResults,
- { slug, demo, skipAssertions = [] }: RecordA11yOptions,
+ { slug, demo, assertions = 'visual', skipAssertions = [] }: RecordA11yOptions,
): void {
const rules: Record = {};
const buckets: ReadonlyArray<[AxeResults['passes'], RuleStatus]> = [
@@ -100,22 +105,20 @@ export function recordA11y(
(ctx.task.meta as { a11y?: A11yMeta }).a11y = meta;
const skip = new Set(skipAssertions);
- const visualViolations = results.violations.filter(
- (v) => VISUAL_RULES.includes(v.id) && !skip.has(v.id),
- );
- const visualIncomplete = results.incomplete.filter(
- (v) => VISUAL_RULES.includes(v.id) && !skip.has(v.id),
- );
+ const shouldAssert = (ruleId: string) =>
+ !skip.has(ruleId) && (assertions === 'all' || VISUAL_RULES.includes(ruleId));
+ const assertedViolations = results.violations.filter((v) => shouldAssert(v.id));
+ const assertedIncomplete = results.incomplete.filter((v) => shouldAssert(v.id));
const failures: string[] = [];
- if (visualViolations.length > 0) {
+ if (assertedViolations.length > 0) {
failures.push(
- `${visualViolations.length} axe violation(s):\n\n${formatResults(visualViolations)}`,
+ `${assertedViolations.length} axe violation(s):\n\n${formatResults(assertedViolations)}`,
);
}
- if (visualIncomplete.length > 0) {
+ if (assertedIncomplete.length > 0) {
failures.push(
- `${visualIncomplete.length} axe incomplete (needs review):\n\n${formatResults(visualIncomplete)}`,
+ `${assertedIncomplete.length} axe incomplete (needs review):\n\n${formatResults(assertedIncomplete)}`,
);
}
if (failures.length > 0) {
diff --git a/test/regressions/demoMeta.test.ts b/test/regressions/demoMeta.test.ts
index e751a21e83723c..068613de396611 100644
--- a/test/regressions/demoMeta.test.ts
+++ b/test/regressions/demoMeta.test.ts
@@ -45,17 +45,30 @@ describe('getConfig', () => {
it('returns the a11y rule for a brace-glob enrolment', () => {
expect(
getConfig(A11Y_RULES, 'docs/data/material/components/buttons/BasicButtons'),
- ).to.deep.include({ enabled: true });
+ ).to.deep.include({ enabled: true, assertions: 'all' });
expect(
- getConfig(A11Y_RULES, 'docs/data/material/components/buttons/ColorButtons'),
- ).to.deep.include({ enabled: true });
+ getConfig(A11Y_RULES, 'docs/data/material/components/buttons/ButtonA11yNonNative'),
+ ).to.deep.include({ enabled: true, assertions: 'all' });
+ });
+
+ it('allows a known Button color-contrast fixture to record failures without asserting them', () => {
+ expect(
+ getConfig(A11Y_RULES, 'docs/data/material/components/buttons/ButtonA11yColorMatrix'),
+ ).to.deep.include({
+ enabled: true,
+ assertions: 'all',
+ skipAssertions: ['color-contrast'],
+ });
});
it('returns undefined for a demo outside a brace-glob enrolment', () => {
- // `buttons` enrols only {BasicButtons,ColorButtons}.
+ // Button a11y enrolment covers @mui/material/Button, not IconButton.
expect(getConfig(A11Y_RULES, 'docs/data/material/components/buttons/DisabledButtons')).to.equal(
undefined,
);
+ expect(getConfig(A11Y_RULES, 'docs/data/material/components/buttons/IconButtons')).to.equal(
+ undefined,
+ );
});
it('honours last-match-wins when multiple rules apply', () => {
diff --git a/test/regressions/demoMeta.ts b/test/regressions/demoMeta.ts
index 74486e2baad307..d052e935dd210e 100644
--- a/test/regressions/demoMeta.ts
+++ b/test/regressions/demoMeta.ts
@@ -37,6 +37,11 @@ export interface A11yRule {
/** Minimatch glob against `docs/data/material/components/{slug}/{Demo}`. */
test: string;
enabled?: boolean;
+ /**
+ * `visual` asserts rules that depend on rendered CSS. `all` asserts every
+ * axe violation/incomplete that is not listed in `skipAssertions`.
+ */
+ assertions?: 'visual' | 'all';
/** Axe rule IDs recorded into results JSON but not asserted on. */
skipAssertions?: string[];
}
@@ -143,6 +148,26 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [
viewportWidth: 1440,
waitForSelector: '.MuiDataGrid-row:not(.MuiDataGrid-rowSkeleton) .MuiDataGrid-cell',
},
+ { test: 'docs/data/material/components/buttons/ButtonA11y*', enabled: false }, // A11y-only coverage fixtures
+ { test: 'docs/data/material/components/buttons/ButtonA11yTextSpacing', enabled: true }, // Visual regression for text spacing (1.4.12); adds no unique axe coverage
+];
+
+// Button docs demos enrolled for axe assertions; IconButton/ButtonBase demos are excluded.
+const BUTTON_A11Y_DEMOS = [
+ 'BasicButtons',
+ 'TextButtons',
+ 'ContainedButtons',
+ 'DisableElevation',
+ 'OutlinedButtons',
+ 'ColorButtons',
+ 'ButtonSizes',
+ 'IconLabelButtons',
+ 'InputFileUpload',
+ 'LoadingButtons',
+ 'CustomizedButtons',
+ 'ButtonA11yNonNative',
+ 'ButtonA11ySemanticStates',
+ 'ButtonA11yTextSpacing',
];
/**
@@ -153,7 +178,17 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [
* Initial PR scope: `buttons` only. Other components onboard incrementally.
*/
export const A11Y_RULES: A11yRule[] = [
- { test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true },
+ {
+ test: `docs/data/material/components/buttons/{${BUTTON_A11Y_DEMOS.join(',')}}`,
+ enabled: true,
+ assertions: 'all',
+ },
+ {
+ test: 'docs/data/material/components/buttons/ButtonA11yColorMatrix',
+ enabled: true,
+ assertions: 'all',
+ skipAssertions: ['color-contrast'],
+ },
];
export interface ParsedRoute {
diff --git a/test/regressions/index.test.js b/test/regressions/index.test.js
index 0b25c2ffb9dcb7..75212082661551 100644
--- a/test/regressions/index.test.js
+++ b/test/regressions/index.test.js
@@ -219,6 +219,7 @@ async function main() {
recordA11y({ task }, results, {
slug: parsed.slug,
demo: parsed.demo,
+ assertions: a11yRule.assertions,
skipAssertions: a11yRule.skipAssertions,
});
}