From 796e748de35581fce1d317a49b3aff12d43fd850 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Tue, 11 Aug 2026 14:02:19 +0200 Subject: [PATCH 01/16] [test][button] Add axe test coverage and WCAG conformance report --- .../buttons/ButtonA11yColorMatrix.js | 40 ++ .../buttons/ButtonA11yColorMatrix.tsx | 40 ++ .../buttons/ButtonA11yColorMatrix.tsx.preview | 9 + .../components/buttons/ButtonA11yNonNative.js | 20 + .../buttons/ButtonA11yNonNative.tsx | 23 + .../buttons/ButtonA11yNonNative.tsx.preview | 6 + .../buttons/ButtonA11ySemanticStates.js | 58 ++ .../buttons/ButtonA11ySemanticStates.tsx | 58 ++ .../buttons/ButtonA11yTextSpacing.js | 40 ++ .../buttons/ButtonA11yTextSpacing.tsx | 40 ++ .../components/buttons/buttons.a11y.json | 616 ++++++++++++++++++ .../mui-material/src/Button/Button.test.js | 162 +++++ .../mui-material/src/Button/accessibility.md | 371 +++++++++++ packages/mui-material/src/accessibility.md | 76 +++ test/regressions/demoMeta.test.ts | 21 +- test/regressions/demoMeta.ts | 32 +- 16 files changed, 1606 insertions(+), 6 deletions(-) create mode 100644 docs/data/material/components/buttons/ButtonA11yColorMatrix.js create mode 100644 docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx create mode 100644 docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview create mode 100644 docs/data/material/components/buttons/ButtonA11yNonNative.js create mode 100644 docs/data/material/components/buttons/ButtonA11yNonNative.tsx create mode 100644 docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview create mode 100644 docs/data/material/components/buttons/ButtonA11ySemanticStates.js create mode 100644 docs/data/material/components/buttons/ButtonA11ySemanticStates.tsx create mode 100644 docs/data/material/components/buttons/ButtonA11yTextSpacing.js create mode 100644 docs/data/material/components/buttons/ButtonA11yTextSpacing.tsx create mode 100644 packages/mui-material/src/Button/accessibility.md create mode 100644 packages/mui-material/src/accessibility.md 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/Button.test.js b/packages/mui-material/src/Button/Button.test.js index 82dd95331e6d77..ab2fdd87b307bc 100644 --- a/packages/mui-material/src/Button/Button.test.js +++ b/packages/mui-material/src/Button/Button.test.js @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import * as React from 'react'; +import { spy } from 'sinon'; import { createRenderer, screen, @@ -1031,4 +1032,165 @@ describe(' + + + , + ); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Middle' })).toHaveFocus(); + + // Tab moves focus back out of the button — it is never captured. + await user.tab(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + + // Shift+Tab moves back onto it. + await user.tab({ shift: true }); + expect(screen.getByRole('button', { name: 'Middle' })).toHaveFocus(); + }); + + describe('2.4.3 Focus Order', () => { + it('is a single tab stop in natural DOM order with no positive tabIndex', async () => { + const { user } = render( + + + + + , + ); + expect(screen.getByRole('button', { name: 'Middle' })).to.have.property('tabIndex', 0); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: 'Middle' })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + }); + + it('removes a disabled button from the tab order', async () => { + const { user } = render( + + + + , + ); + + // Tab skips the disabled button and lands on the next control. + await user.tab(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + }); + + it('removes a loading button from the tab order', async () => { + const { user } = render( + + + + , + ); + + await user.tab(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + }); + }); + + it('2.5.2 Pointer Cancellation: activates on click, but not when released off the target', async () => { + const handleClick = spy(); + const { user } = render( + + +
+ , + ); + const button = screen.getByRole('button', { name: 'Pointer cancellation' }); + + // Press on the button, move away, then release: nothing runs on the down + // event, and releasing off the target cancels the activation. + await user.pointer([ + { keys: '[MouseLeft>]', target: button }, + { target: screen.getByTestId('outside') }, + { keys: '[/MouseLeft]' }, + ]); + expect(handleClick.callCount).to.equal(0); + + // A full click — press and release over the target — activates. + await user.click(button); + expect(handleClick.callCount).to.equal(1); + }); + + it('3.2.1 On Focus: moving keyboard focus to the button does not activate it', async () => { + const handleClick = spy(); + const { user } = render(); + + await user.tab(); + expect(screen.getByRole('button')).toHaveFocus(); + // Focus alone changes no context. + expect(handleClick.callCount).to.equal(0); + }); + + it('3.2.2 On Input: state changes only from explicit activation, never on its own', async () => { + const handleClick = spy(); + const { user } = render( + + + + , + ); + + // Neither the pressed state nor the loading state activates a button on its own. + expect(handleClick.callCount).to.equal(0); + + // The pressed toggle changes context only when the user explicitly activates it. + await user.click(screen.getByRole('button', { name: 'Pressed' })); + expect(handleClick.callCount).to.equal(1); + }); + + describe('2.5.3 Label in Name', () => { + it('uses the visible text as the accessible name', () => { + render(); + + // getByRole with `name` only resolves if the accessible name matches the label. + expect(screen.getByRole('button', { name: 'Save changes' })).not.to.equal(null); + }); + + it('keeps the visible words in the name when a decorative icon is present', () => { + render(); + + expect(screen.getByRole('button', { name: 'Delete' })).not.to.equal(null); + }); + }); + + describe('4.1.2 Name, Role, Value', () => { + it('renders a native button with the correct role', () => { + render(); + + expect(screen.getByRole('button', { name: 'Submit' })).to.have.tagName('button'); + }); + + it('exposes the link role when rendered as an anchor', () => { + render(); + + expect(screen.getByRole('link', { name: 'Pricing' })).to.have.tagName('a'); + }); + + it('reflects the disabled state', () => { + render(); + + expect(screen.getByRole('button')).to.have.property('disabled', true); + }); + }); + }); }); diff --git a/packages/mui-material/src/Button/accessibility.md b/packages/mui-material/src/Button/accessibility.md new file mode 100644 index 00000000000000..1d0a4505c23dc7 --- /dev/null +++ b/packages/mui-material/src/Button/accessibility.md @@ -0,0 +1,371 @@ +# 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 | +| 🚩 Flagged | 8/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 + +`🚩` · `✅ 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. In a UI with a series of buttons, press Tab repeatedly and note the order the buttons receive focus. +2. 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 + +`🚩` · `✅ 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.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. Select a button's label with the mouse (or zoom in) and confirm it behaves like real text (selectable, stays crisp), not an image. + +**Pass:** no button renders its label as an image of text (a logo with an accessible name is exempt). + +#### 2.4.11 Focus Not Obscured (Minimum) · AA + +`🚩` · `✅ 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 + +`🚩` · `✅ 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 + +`🚩` · `⚠️ 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. With a screen reader running (NVDA with Chrome, or VoiceOver with Safari), activate a button that enters the `loading` state for a background task. +2. Press Tab to move focus away from it, then 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. With a screen reader running (NVDA with Chrome, or VoiceOver with Safari), Tab to buttons that carry a start or end icon and confirm each announces the visible label, not the icon. +2. 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. With a screen reader running, focus a plain button, an `href` button, a disabled button, and a non-native (`role="button"`) button. +2. Confirm the plain button announces "button", the `href` one announces "link", the disabled one announces its disabled state, and the non-native one announces "button". + +**Pass:** role and state match the visual presentation for every variant. + +#### 1.4.1 Use of Color · A + +`🚩` · `✅ 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. In a UI with buttons of different colors, turn on a grayscale view (in Chrome DevTools: Rendering tab, Emulate vision deficiencies, Achromatopsia). +2. 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. With a contrast checker (the color picker in browser DevTools shows a ratio), check each button's label against its background, across the color and variant combinations in use. +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 + +`🚩` · `⚠️ 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. With a contrast checker, measure an `outlined` button's 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. + +#### 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 + +`🚩` · `⚠️ 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. Press Tab to move focus across `contained`, `outlined`, and `text` buttons. +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`. + +#### 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. For a file-upload button (`component="label"` wrapping a hidden ``), 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. With a screen reader running, Tab to each button variant in use (native, `href` link, non-native `role="button"`, disabled, loading). +2. Confirm the announced name, role, and disabled state are correct for each. +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 + +#### 1.4.4 Resize Text · AA + +`✅ 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%. Covered by a Playwright test at 200% text size. + +#### 1.4.10 Reflow · AA + +`✅ 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. Covered by a Playwright test at a 320px viewport. + +#### 1.4.12 Text Spacing · AA + +`✅ 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. Covered by a Playwright test applying the WCAG text-spacing overrides. + +#### 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 + +`✅ 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). +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (Tab is not intercepted, and focus moves away freely). + +#### 2.4.3 Focus Order · A + +`✅ 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. +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (default `tabIndex` is `0`; disabled and loading buttons leave the order). + +#### 2.5.2 Pointer Cancellation · A + +`✅ 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. +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (`mousedown` does not activate; `click` does). Covered by unit tests. + +#### 2.5.3 Label in Name · A + +`✅ 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. Covered by unit tests. + +#### 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 + +`✅ 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. +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (focusing the button does not activate it). + +#### 3.2.2 On Input · A + +`✅ 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. +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (toggling `aria-pressed` or `loading` does not activate the button). + +## 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. Flagged 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..b31ce0e48e0619 --- /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. | + +A criterion is **flagged** (`🚩`, shown first in its status line) when its rating is assessed from the component's source but not yet confirmed by a test or recorded review. The flag concerns evidence, not conformance, and does 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/demoMeta.test.ts b/test/regressions/demoMeta.test.ts index 2c6e9218be0dbb..70bea05e07aedf 100644 --- a/test/regressions/demoMeta.test.ts +++ b/test/regressions/demoMeta.test.ts @@ -54,17 +54,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 cc224b1536b949..2c5d913f7b9733 100644 --- a/test/regressions/demoMeta.ts +++ b/test/regressions/demoMeta.ts @@ -168,6 +168,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', ]; /** @@ -175,13 +195,21 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [ * Slug-wide rules use `*`; brace-globs narrow enrolment to specific demos; * later opt-out rules disable individual demos. * - * Initial PR scope: `buttons` only. Other components onboard incrementally. + * Scope: the components with a conformance report under + * `packages/mui-material/src//accessibility.md`. Others onboard + * incrementally. */ export const A11Y_RULES: A11yRule[] = [ { - test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', + 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'], }, ]; From 94e3c366f09b704cfc2eb529fa7d88b79d540211 Mon Sep 17 00:00:00 2001 From: michel Date: Wed, 26 Aug 2026 14:12:55 +0200 Subject: [PATCH 02/16] Drop the empty rows from the reports table Each component PR appends its own row when its report lands. Empty rows read as gaps instead of work in progress. See https://github.com/mui/material-ui/pull/48916#discussion_r3854337052 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013HgD6GEW3UxNRvC2abbTy9 --- packages/mui-material/src/accessibility.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/mui-material/src/accessibility.md b/packages/mui-material/src/accessibility.md index b31ce0e48e0619..48d90442c51d76 100644 --- a/packages/mui-material/src/accessibility.md +++ b/packages/mui-material/src/accessibility.md @@ -65,12 +65,4 @@ Components are rated in isolation against WCAG 2.2 A and AA. The levels are [cum | 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 | | | | | From a32d24c712ce7f564f24bbe6205222f819afc7ec Mon Sep 17 00:00:00 2001 From: michel Date: Wed, 26 Aug 2026 15:50:24 +0200 Subject: [PATCH 03/16] review findings --- packages/mui-material/src/Button/accessibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mui-material/src/Button/accessibility.md b/packages/mui-material/src/Button/accessibility.md index 1d0a4505c23dc7..d917791cccffe4 100644 --- a/packages/mui-material/src/Button/accessibility.md +++ b/packages/mui-material/src/Button/accessibility.md @@ -12,7 +12,7 @@ Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility ## Known gaps -- ⚠️ **1.4.3 Contrast (Minimum).** `info` and `warning` contained buttons fall short of 4.5:1. +- ⚠️ **1.4.3 Contrast (Minimum).** `info` and `warning` buttons fall short of 4.5:1 in every variant. - ⚠️ **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. @@ -302,7 +302,7 @@ Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility `✅ 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. -- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (`mousedown` does not activate; `click` does). Covered by unit tests. +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (`mousedown` does not activate; `click` does). #### 2.5.3 Label in Name · A From bfea04ba200d7de933841194e7f40bd26036c3ec Mon Sep 17 00:00:00 2001 From: michel Date: Wed, 26 Aug 2026 15:54:08 +0200 Subject: [PATCH 04/16] review findings --- packages/mui-material/src/Button/accessibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-material/src/Button/accessibility.md b/packages/mui-material/src/Button/accessibility.md index d917791cccffe4..6716e345e39ac6 100644 --- a/packages/mui-material/src/Button/accessibility.md +++ b/packages/mui-material/src/Button/accessibility.md @@ -309,7 +309,7 @@ Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility `✅ 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. Covered by unit tests. +- An `aria-label` that omits or reorders the visible words breaks this. Compare the visible text to the computed name. #### 2.5.8 Target Size (Minimum) · AA From c3e1e87ffb3ab40ac91a7613342a23f3edbc9e64 Mon Sep 17 00:00:00 2001 From: michel Date: Wed, 26 Aug 2026 16:02:13 +0200 Subject: [PATCH 05/16] added a table to show which color combinations fail and by how much the contrast ratio is off --- .../mui-material/src/Button/accessibility.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/mui-material/src/Button/accessibility.md b/packages/mui-material/src/Button/accessibility.md index 6716e345e39ac6..6f477593d8700d 100644 --- a/packages/mui-material/src/Button/accessibility.md +++ b/packages/mui-material/src/Button/accessibility.md @@ -153,11 +153,22 @@ Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility `⚠️ Partially Supports` · `● Component` -- `info` and `warning` contained buttons do not meet `4.5:1`. +- `info` and `warning` buttons do not meet `4.5:1` in any variant. - axe-core `color-contrast` checks the default state. - `:hover` and `:active` colors need a visual check. - Disabled buttons are exempt. +The shortfalls, measured from the default light palette (all pairs against `#fff`): + +| Combination | Foreground | Background | Ratio | Required | +| :---------------------------------------- | :--------- | :--------- | -----: | -------: | +| `contained` `color="warning"` | `#fff` | `#ed6c02` | 3.11:1 | 4.5:1 | +| `contained` `color="info"` | `#fff` | `#0288d1` | 3.86:1 | 4.5:1 | +| `text`/`outlined` `color="warning"` label | `#ed6c02` | `#fff` | 3.11:1 | 4.5:1 | +| `text`/`outlined` `color="info"` label | `#0288d1` | `#fff` | 3.86:1 | 4.5:1 | + +Every other `variant` × `color` combination clears `4.5:1`; the closest pass is `primary` at `4.6:1`. The button label (14px, weight 500) does not qualify as WCAG large text, so the `3:1` threshold never applies here. + **Manual testing steps** 1. With a contrast checker (the color picker in browser DevTools shows a ratio), check each button's label against its background, across the color and variant combinations in use. @@ -331,7 +342,7 @@ Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility - 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. -- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (toggling `aria-pressed` or `loading` does not activate the button). +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (rendering with `aria-pressed` or `loading` does not fire `onClick`). ## Not applicable @@ -365,7 +376,7 @@ Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility ## Scope and test environment - **Standard.** WCAG 2.2, Level A and AA. -- **Component version.** `@mui/material` 9.1.1. +- **Component version.** `@mui/material` 9.x. - **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. Flagged criteria are assessed from source pending a review with NVDA, JAWS, and VoiceOver. From 16be33c4de41774887fb5d45d7f1a1819820e0aa Mon Sep 17 00:00:00 2001 From: michel Date: Wed, 26 Aug 2026 16:55:04 +0200 Subject: [PATCH 06/16] Add WCAG 2.1 contrast ratio tests for default button palette --- .../mui-material/src/Button/Button.test.js | 74 ++++++++++++++++++- test/regressions/demoMeta.ts | 9 +++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/mui-material/src/Button/Button.test.js b/packages/mui-material/src/Button/Button.test.js index ab2fdd87b307bc..81bb981b23643d 100644 --- a/packages/mui-material/src/Button/Button.test.js +++ b/packages/mui-material/src/Button/Button.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { assert, describe, expect, it, vi } from 'vitest'; import * as React from 'react'; import { spy } from 'sinon'; import { @@ -14,6 +14,7 @@ import Button, { buttonClasses as classes } from '@mui/material/Button'; import ButtonBase, { touchRippleClasses } from '@mui/material/ButtonBase'; import describeConformance from '../../test/describeConformance'; import * as ripple from '../../test/ripple'; +import accessibilityReport from './accessibility.md?raw'; describe(' - - , + const { setProps, user } = render( + , ); - // Neither the pressed state nor the loading state activates a button on its own. + // Flipping the pressed and loading state programmatically must not activate the button. + setProps({ 'aria-pressed': true }); + setProps({ loading: true }); expect(handleClick.callCount).to.equal(0); - // The pressed toggle changes context only when the user explicitly activates it. - await user.click(screen.getByRole('button', { name: 'Pressed' })); + // Only explicit activation runs the handler. `loading` is turned off first, + // since a loading button is removed from interaction. + setProps({ loading: false }); + await user.click(screen.getByRole('button')); expect(handleClick.callCount).to.equal(1); }); From 0c3a5a81a169353f89c0104c05288b6e41f500d1 Mon Sep 17 00:00:00 2001 From: michel Date: Wed, 26 Aug 2026 17:00:51 +0200 Subject: [PATCH 08/16] Clarify button accessibility behavior and unit test coverage --- packages/mui-material/src/Button/accessibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mui-material/src/Button/accessibility.md b/packages/mui-material/src/Button/accessibility.md index 6f477593d8700d..cd11cc4acfbf2e 100644 --- a/packages/mui-material/src/Button/accessibility.md +++ b/packages/mui-material/src/Button/accessibility.md @@ -340,9 +340,9 @@ Every other `variant` × `color` combination clears `4.5:1`; the closest pass is `✅ Supports` · `◐ Shared` -- Toggling the button's setting (`loading` to disabled, or an `aria-pressed` toggle) changes no context on its own. +- Programmatically toggling the button's setting (`aria-pressed`, or `loading` to disabled) fires no handler and changes no context on its own. Activation comes only from explicit user input. - Whether an author's handler couples that change to navigation or a new window without warning is an author decision. -- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js) (rendering with `aria-pressed` or `loading` does not fire `onClick`). +- Confirmed by a unit test in [`./Button.test.js`](./Button.test.js): flipping `aria-pressed` and `loading` on a rendered button fires no `onClick`, and a real click does. ## Not applicable From 258169597b89d77780457f597c0dd269e6637c11 Mon Sep 17 00:00:00 2001 From: michel Date: Wed, 26 Aug 2026 17:03:17 +0200 Subject: [PATCH 09/16] Remove unused accessibility preview files for buttons --- .../components/buttons/ButtonA11yColorMatrix.tsx.preview | 9 --------- .../components/buttons/ButtonA11yNonNative.tsx.preview | 6 ------ 2 files changed, 15 deletions(-) delete mode 100644 docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview delete mode 100644 docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview diff --git a/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview b/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview deleted file mode 100644 index 93bb23732292e4..00000000000000 --- a/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx.preview +++ /dev/null @@ -1,9 +0,0 @@ -{variants.map((variant) => ( -
- {colors.map((color) => ( - - ))} -
-))} \ No newline at end of file diff --git a/docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview b/docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview deleted file mode 100644 index ea5874fce148de..00000000000000 --- a/docs/data/material/components/buttons/ButtonA11yNonNative.tsx.preview +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file From 6e3e3fbac5f285a1bebda010439767ff2f9aabdc Mon Sep 17 00:00:00 2001 From: michel Date: Thu, 27 Aug 2026 14:47:11 +0200 Subject: [PATCH 10/16] Move button accessibility fixtures and tests to regression folder, update paths and metadata --- .../buttons/ButtonA11yColorMatrix.js | 40 ------------- .../components/buttons/ButtonA11yNonNative.js | 20 ------- .../buttons/ButtonA11ySemanticStates.js | 58 ------------------- .../buttons/ButtonA11yTextSpacing.js | 40 ------------- test/regressions/demoMeta.test.ts | 12 ++-- test/regressions/demoMeta.ts | 35 ++++++++--- .../buttons/ButtonA11yColorMatrix.tsx | 10 +--- .../fixtures}/buttons/ButtonA11yNonNative.tsx | 11 ++-- .../buttons/ButtonA11ySemanticStates.tsx | 7 +-- .../buttons/ButtonA11yTextSpacing.tsx | 12 +--- 10 files changed, 43 insertions(+), 202 deletions(-) delete mode 100644 docs/data/material/components/buttons/ButtonA11yColorMatrix.js delete mode 100644 docs/data/material/components/buttons/ButtonA11yNonNative.js delete mode 100644 docs/data/material/components/buttons/ButtonA11ySemanticStates.js delete mode 100644 docs/data/material/components/buttons/ButtonA11yTextSpacing.js rename {docs/data/material/components => test/regressions/fixtures}/buttons/ButtonA11yColorMatrix.tsx (85%) rename {docs/data/material/components => test/regressions/fixtures}/buttons/ButtonA11yNonNative.tsx (71%) rename {docs/data/material/components => test/regressions/fixtures}/buttons/ButtonA11ySemanticStates.tsx (91%) rename {docs/data/material/components => test/regressions/fixtures}/buttons/ButtonA11yTextSpacing.tsx (76%) diff --git a/docs/data/material/components/buttons/ButtonA11yColorMatrix.js b/docs/data/material/components/buttons/ButtonA11yColorMatrix.js deleted file mode 100644 index 4f4a2fa001b8cb..00000000000000 --- a/docs/data/material/components/buttons/ButtonA11yColorMatrix.js +++ /dev/null @@ -1,40 +0,0 @@ -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/ButtonA11yNonNative.js b/docs/data/material/components/buttons/ButtonA11yNonNative.js deleted file mode 100644 index e27cb3960763a8..00000000000000 --- a/docs/data/material/components/buttons/ButtonA11yNonNative.js +++ /dev/null @@ -1,20 +0,0 @@ -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/ButtonA11ySemanticStates.js b/docs/data/material/components/buttons/ButtonA11ySemanticStates.js deleted file mode 100644 index f4c5156ef19827..00000000000000 --- a/docs/data/material/components/buttons/ButtonA11ySemanticStates.js +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 3bdcf3f0c54528..00000000000000 --- a/docs/data/material/components/buttons/ButtonA11yTextSpacing.js +++ /dev/null @@ -1,40 +0,0 @@ -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/test/regressions/demoMeta.test.ts b/test/regressions/demoMeta.test.ts index 70bea05e07aedf..057b9a93714740 100644 --- a/test/regressions/demoMeta.test.ts +++ b/test/regressions/demoMeta.test.ts @@ -2,8 +2,12 @@ import { describe, it, expect } from 'vitest'; import { A11Y_RULES, SCREENSHOT_RULES, getConfig, parseRoute } from './demoMeta'; describe('parseRoute', () => { - it('returns null for non-component routes (regression fixtures)', () => { - expect(parseRoute('/regression-Rating/FocusVisibleRating')).to.equal(null); + it('parses a regression fixture route into its fixtures path, suite as slug', () => { + expect(parseRoute('/regression-Rating/FocusVisibleRating')).to.deep.equal({ + path: 'test/regressions/fixtures/Rating/FocusVisibleRating', + slug: 'Rating', + demo: 'FocusVisibleRating', + }); }); it('parses a docs-components route into path/slug/demo', () => { @@ -56,13 +60,13 @@ describe('getConfig', () => { getConfig(A11Y_RULES, 'docs/data/material/components/buttons/BasicButtons'), ).to.deep.include({ enabled: true, assertions: 'all' }); expect( - getConfig(A11Y_RULES, 'docs/data/material/components/buttons/ButtonA11yNonNative'), + getConfig(A11Y_RULES, 'test/regressions/fixtures/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'), + getConfig(A11Y_RULES, 'test/regressions/fixtures/buttons/ButtonA11yColorMatrix'), ).to.deep.include({ enabled: true, assertions: 'all', diff --git a/test/regressions/demoMeta.ts b/test/regressions/demoMeta.ts index b33287871c3ae8..c832a918e015a2 100644 --- a/test/regressions/demoMeta.ts +++ b/test/regressions/demoMeta.ts @@ -168,8 +168,8 @@ 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 + { test: 'test/regressions/fixtures/buttons/ButtonA11y*', enabled: false }, // A11y-only coverage fixtures + { test: 'test/regressions/fixtures/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. @@ -185,9 +185,6 @@ const BUTTON_A11Y_DEMOS = [ 'InputFileUpload', 'LoadingButtons', 'CustomizedButtons', - 'ButtonA11yNonNative', - 'ButtonA11ySemanticStates', - 'ButtonA11yTextSpacing', ]; /** @@ -205,6 +202,14 @@ export const A11Y_RULES: A11yRule[] = [ enabled: true, assertions: 'all', }, + // A11y-only fixtures live under `test/regressions/fixtures/buttons/` (no + // docs page consumes them); the suite name maps their results into the + // same `buttons.a11y.json` as the docs demos above. + { + test: 'test/regressions/fixtures/buttons/{ButtonA11yNonNative,ButtonA11ySemanticStates,ButtonA11yTextSpacing}', + enabled: true, + assertions: 'all', + }, // `color-contrast` is a known, documented product gap, not a regression: // `info` and `warning` fail 4.5:1 in every variant with the default palette // Asserting it would keep CI permanently red, so the failure is recorded in @@ -215,7 +220,7 @@ export const A11Y_RULES: A11yRule[] = [ // `createTheme()` and fail when the failing set or the documented table // goes stale. { - test: 'docs/data/material/components/buttons/ButtonA11yColorMatrix', + test: 'test/regressions/fixtures/buttons/ButtonA11yColorMatrix', enabled: true, assertions: 'all', skipAssertions: ['color-contrast'], @@ -231,20 +236,27 @@ export interface ParsedRoute { const COMPONENT_ROUTE_REGEX = /^\/docs-components-([^/]+)\/(.+)$/; const COMPOSITE_ROUTE_REGEX = /^\/docs-product-([^/]+)\/(.+)$/; const TEMPLATE_ROUTE_REGEX = /^\/docs-getting-started-templates-([^/]+)\/(.+)$/; +const FIXTURE_ROUTE_REGEX = /^\/regression-([^/]+)\/(.+)$/; /** - * Map a VRT route to its docs path + slug + demo, or `null` for non-component - * routes (regression fixtures). + * Map a VRT route to its source path + slug + demo. * - * Recognises two route shapes: + * Recognises four route shapes: * - `/docs-components-{slug}/{Demo}` → `docs/data/material/components/{slug}/{Demo}` * - `/docs-product-{product}/{Name}` → `docs/src/components/product{Product}/{Name}` * - `/docs-getting-started-templates-{slug}/{Demo}` → * `docs/data/material/getting-started/templates/{slug}/{Demo}` + * - `/regression-{suite}/{Name}` → `test/regressions/fixtures/{suite}/{Name}` * * The template shape is matched by its literal prefix: `fixtures.js` joins the * directory segments with `-`, so `getting-started-templates-crud-dashboard` * cannot be split back into directories without knowing where the slug starts. + * + * For fixture routes the suite directory doubles as the slug. Name a fixture + * suite after a docs slug (lowercase, for example `fixtures/buttons/`) to + * record its axe results into that slug's committed `{slug}.a11y.json` — for + * a11y-only fixtures that must not live in `docs/data` because no docs page + * consumes them. */ export function parseRoute(route: string): ParsedRoute | null { const componentMatch = route.match(COMPONENT_ROUTE_REGEX); @@ -269,6 +281,11 @@ export function parseRoute(route: string): ParsedRoute | null { const dir = `product${product.charAt(0).toUpperCase()}${product.slice(1)}`; return { path: `docs/src/components/${dir}/${demo}`, slug: product, demo }; } + const fixtureMatch = route.match(FIXTURE_ROUTE_REGEX); + if (fixtureMatch) { + const [, suite, demo] = fixtureMatch; + return { path: `test/regressions/fixtures/${suite}/${demo}`, slug: suite, demo }; + } return null; } diff --git a/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx b/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.tsx similarity index 85% rename from docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx rename to test/regressions/fixtures/buttons/ButtonA11yColorMatrix.tsx index af7b1d5715d212..c85e40e8837cfb 100644 --- a/docs/data/material/components/buttons/ButtonA11yColorMatrix.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.tsx @@ -2,15 +2,7 @@ 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; +const colors = ['primary', 'secondary', 'success', 'error', 'info', 'warning', 'inherit'] as const; export default function ButtonA11yColorMatrix() { return ( diff --git a/docs/data/material/components/buttons/ButtonA11yNonNative.tsx b/test/regressions/fixtures/buttons/ButtonA11yNonNative.tsx similarity index 71% rename from docs/data/material/components/buttons/ButtonA11yNonNative.tsx rename to test/regressions/fixtures/buttons/ButtonA11yNonNative.tsx index 225a0d0e68d22b..1e27b70661068a 100644 --- a/docs/data/material/components/buttons/ButtonA11yNonNative.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11yNonNative.tsx @@ -2,12 +2,11 @@ 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
; -}); +const CustomDivButton = React.forwardRef>( + function CustomDivButton(props, ref) { + return
; + }, +); export default function ButtonA11yNonNative() { return ( diff --git a/docs/data/material/components/buttons/ButtonA11ySemanticStates.tsx b/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.tsx similarity index 91% rename from docs/data/material/components/buttons/ButtonA11ySemanticStates.tsx rename to test/regressions/fixtures/buttons/ButtonA11ySemanticStates.tsx index f4c5156ef19827..4fc4c7877e7cc6 100644 --- a/docs/data/material/components/buttons/ButtonA11ySemanticStates.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.tsx @@ -33,12 +33,7 @@ export default function ButtonA11ySemanticStates() { - diff --git a/docs/data/material/components/buttons/ButtonA11yTextSpacing.tsx b/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.tsx similarity index 76% rename from docs/data/material/components/buttons/ButtonA11yTextSpacing.tsx rename to test/regressions/fixtures/buttons/ButtonA11yTextSpacing.tsx index 3bdcf3f0c54528..5ad8814ee08ac2 100644 --- a/docs/data/material/components/buttons/ButtonA11yTextSpacing.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.tsx @@ -20,18 +20,10 @@ export default function ButtonA11yTextSpacing() { Review accessibility settings before continuing - - From 2a2fdeaa7a3b6007ec212625bd00c3ee8d253563 Mon Sep 17 00:00:00 2001 From: michel Date: Thu, 27 Aug 2026 14:53:12 +0200 Subject: [PATCH 11/16] switched from .tsx files to pure .js files for button regression fixtures --- ...nA11yColorMatrix.tsx => ButtonA11yColorMatrix.js} | 12 ++++++++++-- ...uttonA11yNonNative.tsx => ButtonA11yNonNative.js} | 8 +++----- ...emanticStates.tsx => ButtonA11ySemanticStates.js} | 7 ++++++- ...nA11yTextSpacing.tsx => ButtonA11yTextSpacing.js} | 12 ++++++++++-- 4 files changed, 29 insertions(+), 10 deletions(-) rename test/regressions/fixtures/buttons/{ButtonA11yColorMatrix.tsx => ButtonA11yColorMatrix.js} (80%) rename test/regressions/fixtures/buttons/{ButtonA11yNonNative.tsx => ButtonA11yNonNative.js} (71%) rename test/regressions/fixtures/buttons/{ButtonA11ySemanticStates.tsx => ButtonA11ySemanticStates.js} (91%) rename test/regressions/fixtures/buttons/{ButtonA11yTextSpacing.tsx => ButtonA11yTextSpacing.js} (76%) diff --git a/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.tsx b/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.js similarity index 80% rename from test/regressions/fixtures/buttons/ButtonA11yColorMatrix.tsx rename to test/regressions/fixtures/buttons/ButtonA11yColorMatrix.js index c85e40e8837cfb..4f4a2fa001b8cb 100644 --- a/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.js @@ -1,8 +1,16 @@ 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; +const variants = ['text', 'outlined', 'contained']; +const colors = [ + 'primary', + 'secondary', + 'success', + 'error', + 'info', + 'warning', + 'inherit', +]; export default function ButtonA11yColorMatrix() { return ( diff --git a/test/regressions/fixtures/buttons/ButtonA11yNonNative.tsx b/test/regressions/fixtures/buttons/ButtonA11yNonNative.js similarity index 71% rename from test/regressions/fixtures/buttons/ButtonA11yNonNative.tsx rename to test/regressions/fixtures/buttons/ButtonA11yNonNative.js index 1e27b70661068a..e27cb3960763a8 100644 --- a/test/regressions/fixtures/buttons/ButtonA11yNonNative.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11yNonNative.js @@ -2,11 +2,9 @@ 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
; - }, -); +const CustomDivButton = React.forwardRef(function CustomDivButton(props, ref) { + return
; +}); export default function ButtonA11yNonNative() { return ( diff --git a/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.tsx b/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.js similarity index 91% rename from test/regressions/fixtures/buttons/ButtonA11ySemanticStates.tsx rename to test/regressions/fixtures/buttons/ButtonA11ySemanticStates.js index 4fc4c7877e7cc6..f4c5156ef19827 100644 --- a/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.js @@ -33,7 +33,12 @@ export default function ButtonA11ySemanticStates() { - diff --git a/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.tsx b/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.js similarity index 76% rename from test/regressions/fixtures/buttons/ButtonA11yTextSpacing.tsx rename to test/regressions/fixtures/buttons/ButtonA11yTextSpacing.js index 5ad8814ee08ac2..3bdcf3f0c54528 100644 --- a/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.tsx +++ b/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.js @@ -20,10 +20,18 @@ export default function ButtonA11yTextSpacing() { Review accessibility settings before continuing - - From c42394399952839316bfeba114044dd2fc99e107 Mon Sep 17 00:00:00 2001 From: michel Date: Thu, 27 Aug 2026 15:21:45 +0200 Subject: [PATCH 12/16] prettier --- .../fixtures/buttons/ButtonA11yColorMatrix.js | 10 +--------- .../fixtures/buttons/ButtonA11ySemanticStates.js | 7 +------ .../fixtures/buttons/ButtonA11yTextSpacing.js | 12 ++---------- 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.js b/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.js index 4f4a2fa001b8cb..c952f7248ea90c 100644 --- a/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.js +++ b/test/regressions/fixtures/buttons/ButtonA11yColorMatrix.js @@ -2,15 +2,7 @@ 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', -]; +const colors = ['primary', 'secondary', 'success', 'error', 'info', 'warning', 'inherit']; export default function ButtonA11yColorMatrix() { return ( diff --git a/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.js b/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.js index f4c5156ef19827..4fc4c7877e7cc6 100644 --- a/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.js +++ b/test/regressions/fixtures/buttons/ButtonA11ySemanticStates.js @@ -33,12 +33,7 @@ export default function ButtonA11ySemanticStates() { - diff --git a/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.js b/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.js index 3bdcf3f0c54528..5ad8814ee08ac2 100644 --- a/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.js +++ b/test/regressions/fixtures/buttons/ButtonA11yTextSpacing.js @@ -20,18 +20,10 @@ export default function ButtonA11yTextSpacing() { Review accessibility settings before continuing - - From 2f85bea1ac46fde4921d92ff58eaee46fc537106 Mon Sep 17 00:00:00 2001 From: michel Date: Fri, 28 Aug 2026 13:56:27 +0200 Subject: [PATCH 13/16] improved tab walking of the buttons in tests --- .../mui-material/src/Button/Button.test.js | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/mui-material/src/Button/Button.test.js b/packages/mui-material/src/Button/Button.test.js index 69824e5c31f802..11ee6c982e3108 100644 --- a/packages/mui-material/src/Button/Button.test.js +++ b/packages/mui-material/src/Button/Button.test.js @@ -1050,57 +1050,65 @@ describe(' - - - , - ); - expect(screen.getByRole('button', { name: 'Middle' })).to.have.property('tabIndex', 0); - - await user.tab(); - expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); - await user.tab(); - expect(screen.getByRole('button', { name: 'Middle' })).toHaveFocus(); - await user.tab(); - expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + it('is a single tab stop in natural DOM order with no positive tabIndex', () => { + render(); + expect(screen.getByRole('button')).to.have.property('tabIndex', 0); }); it('removes a disabled button from the tab order', async () => { const { user } = render( + , ); + await user.tab(); + expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); + // Tab skips the disabled button and lands on the next control. await user.tab(); expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + + // Backwards tabbing also skips the disabled button and lands back on the first control. + await user.tab({ shift: true }); + expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); }); it('removes a loading button from the tab order', async () => { const { user } = render( + , ); + await user.tab(); + expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); + + // Tab skips the disabled button and lands on the next control. await user.tab(); expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + + // Backwards tabbing also skips the disabled button and lands back on the first control. + await user.tab({ shift: true }); + expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); }); }); From d6033f15b3de74642cd281ed199c14f8e066590f Mon Sep 17 00:00:00 2001 From: michel Date: Fri, 28 Aug 2026 14:01:24 +0200 Subject: [PATCH 14/16] Remove the markdown-coupled contrast tests The contrast guard moves to theme-level contract tests in a separate PR. Parsing the report table from a unit test was brittle, and the palette facts are not Button-specific. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013HgD6GEW3UxNRvC2abbTy9 --- .../mui-material/src/Button/Button.test.js | 74 +------------------ test/regressions/demoMeta.ts | 7 +- 2 files changed, 4 insertions(+), 77 deletions(-) diff --git a/packages/mui-material/src/Button/Button.test.js b/packages/mui-material/src/Button/Button.test.js index 11ee6c982e3108..2cf040e1a9d11a 100644 --- a/packages/mui-material/src/Button/Button.test.js +++ b/packages/mui-material/src/Button/Button.test.js @@ -1,4 +1,4 @@ -import { assert, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import * as React from 'react'; import { spy } from 'sinon'; import { @@ -14,7 +14,6 @@ import Button, { buttonClasses as classes } from '@mui/material/Button'; import ButtonBase, { touchRippleClasses } from '@mui/material/ButtonBase'; import describeConformance from '../../test/describeConformance'; import * as ripple from '../../test/ripple'; -import accessibilityReport from './accessibility.md?raw'; describe('