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 ( + + + + + + + + + + + + + + ); +} 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 ( + + + + + + + + + + + + + + ); +} 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 ( + + + + + + + + ); +} 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 ( + + + + + + + + ); +} 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, }); }