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..2cf040e1a9d11a 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,172 @@ 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(); + + // Another Shift+Tab moves out of the button - still not captured. + await user.tab({ shift: true }); + expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus(); + }); + + describe('2.4.3 Focus Order', () => { + 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(); + }); + }); + + 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: toggling the pressed or loading state does not activate the button', async () => { + const handleClick = spy(); + const { setProps, user } = render( + , + ); + + // 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); + + // 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); + }); + + 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..cd11cc4acfbf2e --- /dev/null +++ b/packages/mui-material/src/Button/accessibility.md @@ -0,0 +1,382 @@ +# 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` 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. + +## 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` 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. +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). + +#### 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. + +#### 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` + +- 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): flipping `aria-pressed` and `loading` on a rendered button fires no `onClick`, and a real click does. + +## 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.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. diff --git a/packages/mui-material/src/accessibility.md b/packages/mui-material/src/accessibility.md new file mode 100644 index 00000000000000..48d90442c51d76 --- /dev/null +++ b/packages/mui-material/src/accessibility.md @@ -0,0 +1,68 @@ +# 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 | +| :---------------------------------- | :---------- | :-------------------- | :------------------ | :---------------- | +| [Button](./Button/accessibility.md) | 23 | 4 | 0 | 28 | diff --git a/test/regressions/a11y/a11yReporter.ts b/test/regressions/a11y/a11yReporter.ts index 0bb2d8434430e6..3d7757b4cb0569 100644 --- a/test/regressions/a11y/a11yReporter.ts +++ b/test/regressions/a11y/a11yReporter.ts @@ -70,9 +70,24 @@ export default class A11yReporter implements Reporter { // One file per slug, co-located with the component's other files. The docs // toolbar reads these via a webpack require.context (eager) at build time. + // + // Only docs slugs that already exist accept results. Docs-demo routes + // always pass; the check exists for a11y fixture suites, whose directory + // name doubles as the slug. Compare against the exact on-disk names — + // `existsSync` folds case on macOS (`Rating` matches `rating/`), so a + // wrong-case suite must fail here on every platform, not only on Linux CI. + const docsSlugs = new Set(fs.readdirSync(COMPONENTS_DIR)); for (const [slug, metas] of bySlug) { + if (!docsSlugs.has(slug)) { + const routes = metas.map((meta) => `${meta.slug}/${meta.demo}`).join(', '); + throw new Error( + `a11y slug "${slug}" (from ${routes}) has no directory under ` + + `${path.relative(process.cwd(), COMPONENTS_DIR)}. ` + + `Name a11y fixture suites after an existing docs slug (lowercase) — ` + + `see parseRoute() in test/regressions/demoMeta.ts.`, + ); + } const slugDir = path.join(COMPONENTS_DIR, slug); - fs.mkdirSync(slugDir, { recursive: true }); const sorted = [...metas].sort((a, b) => a.demo.localeCompare(b.demo)); const file: Record = {}; for (const meta of sorted) { diff --git a/test/regressions/a11y/fixtures/buttons/ButtonA11yColorMatrix.js b/test/regressions/a11y/fixtures/buttons/ButtonA11yColorMatrix.js new file mode 100644 index 00000000000000..c952f7248ea90c --- /dev/null +++ b/test/regressions/a11y/fixtures/buttons/ButtonA11yColorMatrix.js @@ -0,0 +1,32 @@ +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/test/regressions/a11y/fixtures/buttons/ButtonA11yNonNative.js b/test/regressions/a11y/fixtures/buttons/ButtonA11yNonNative.js new file mode 100644 index 00000000000000..e27cb3960763a8 --- /dev/null +++ b/test/regressions/a11y/fixtures/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/test/regressions/a11y/fixtures/buttons/ButtonA11ySemanticStates.js b/test/regressions/a11y/fixtures/buttons/ButtonA11ySemanticStates.js new file mode 100644 index 00000000000000..4fc4c7877e7cc6 --- /dev/null +++ b/test/regressions/a11y/fixtures/buttons/ButtonA11ySemanticStates.js @@ -0,0 +1,53 @@ +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/test/regressions/a11y/fixtures/buttons/ButtonA11yTextSpacing.js b/test/regressions/a11y/fixtures/buttons/ButtonA11yTextSpacing.js new file mode 100644 index 00000000000000..5ad8814ee08ac2 --- /dev/null +++ b/test/regressions/a11y/fixtures/buttons/ButtonA11yTextSpacing.js @@ -0,0 +1,32 @@ +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 2c6e9218be0dbb..e4df68365b39e1 100644 --- a/test/regressions/demoMeta.test.ts +++ b/test/regressions/demoMeta.test.ts @@ -2,7 +2,15 @@ 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)', () => { + it('parses an a11y fixture route into its a11y/fixtures path, suite as slug', () => { + expect(parseRoute('/a11y-buttons/ButtonA11yNonNative')).to.deep.equal({ + path: 'test/regressions/a11y/fixtures/buttons/ButtonA11yNonNative', + slug: 'buttons', + demo: 'ButtonA11yNonNative', + }); + }); + + it('returns null for a screenshot-only regression fixture route', () => { expect(parseRoute('/regression-Rating/FocusVisibleRating')).to.equal(null); }); @@ -54,17 +62,39 @@ 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'), + getConfig(A11Y_RULES, 'test/regressions/a11y/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, 'test/regressions/a11y/fixtures/buttons/ButtonA11yColorMatrix'), + ).to.deep.include({ + enabled: true, + assertions: 'all', + skipAssertions: ['color-contrast'], + }); + }); + + it('keeps the a11y fixture tree screenshot-off, except explicit re-enrolments', () => { + expect( + getConfig(SCREENSHOT_RULES, 'test/regressions/a11y/fixtures/buttons/ButtonA11yColorMatrix'), + ).to.deep.include({ enabled: false }); + expect( + getConfig(SCREENSHOT_RULES, 'test/regressions/a11y/fixtures/buttons/ButtonA11yTextSpacing'), ).to.deep.include({ enabled: true }); }); 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..839c213ee61bff 100644 --- a/test/regressions/demoMeta.ts +++ b/test/regressions/demoMeta.ts @@ -3,8 +3,9 @@ * screenshots, one for axe — so editing one tool can never stomp on the * other. Each list is evaluated last-match-wins (no inheritance: an override * rule must restate every field it cares about) against the docs path - * `docs/data/material/components/{slug}/{Demo}` (component demos) or - * `docs/src/components/product{Product}/{Name}` (landing-page composites). + * `docs/data/material/components/{slug}/{Demo}` (component demos), + * `docs/src/components/product{Product}/{Name}` (landing-page composites), or + * `test/regressions/a11y/fixtures/{slug}/{Demo}` (a11y-only fixtures). * * Whole-slug exclusions where *no* tool wants anything live in the * `index.jsx` glob — dropping them from the bundle entirely, not just from @@ -168,6 +169,25 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [ viewportWidth: 1440, waitForSelector: '.MuiDataGrid-row:not(.MuiDataGrid-rowSkeleton) .MuiDataGrid-cell', }, + // The a11y fixture tree exists for axe, so screenshots are off by default. + // Later rules re-enable single fixtures that also guard a visual state. + { test: 'test/regressions/a11y/fixtures/**', enabled: false }, // A11y-only coverage fixtures + { test: 'test/regressions/a11y/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. +const BUTTON_A11Y_DEMOS = [ + 'BasicButtons', + 'TextButtons', + 'ContainedButtons', + 'DisableElevation', + 'OutlinedButtons', + 'ColorButtons', + 'ButtonSizes', + 'IconLabelButtons', + 'InputFileUpload', + 'LoadingButtons', + 'CustomizedButtons', ]; /** @@ -175,14 +195,38 @@ 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', + }, + // A11y-only fixtures live under `test/regressions/a11y/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/a11y/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 + // `buttons.a11y.json` (status only, a tripwire for flips) and documented with + // measured ratios in `packages/mui-material/src/Button/accessibility.md` + // § 1.4.3. A palette change cannot go unnoticed: the contrast contract + // tests recompute the ratios from `createTheme()` and fail when the + // failing set drifts. + { + test: 'test/regressions/a11y/fixtures/buttons/ButtonA11yColorMatrix', + enabled: true, + assertions: 'all', + skipAssertions: ['color-contrast'], + }, ]; export interface ParsedRoute { @@ -194,20 +238,32 @@ export interface ParsedRoute { const COMPONENT_ROUTE_REGEX = /^\/docs-components-([^/]+)\/(.+)$/; const COMPOSITE_ROUTE_REGEX = /^\/docs-product-([^/]+)\/(.+)$/; const TEMPLATE_ROUTE_REGEX = /^\/docs-getting-started-templates-([^/]+)\/(.+)$/; +const A11Y_FIXTURE_ROUTE_REGEX = /^\/a11y-([^/]+)\/(.+)$/; /** - * 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, or `null` for + * `/regression-*` screenshot fixtures (always screenshot, never axe). * - * 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}` + * - `/a11y-{slug}/{Demo}` → `test/regressions/a11y/fixtures/{slug}/{Demo}` * * 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 a11y fixture routes the suite directory doubles as the slug. Name a + * suite after an existing docs slug (lowercase, for example + * `a11y/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. The a11y reporter rejects a + * slug without a docs directory. These suites live outside + * `test/regressions/fixtures/` because a lowercase slug directory next to a + * PascalCase screenshot suite (`rating/` next to `Rating/`) would fold into + * one directory on case-insensitive file systems. */ export function parseRoute(route: string): ParsedRoute | null { const componentMatch = route.match(COMPONENT_ROUTE_REGEX); @@ -232,6 +288,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 a11yFixtureMatch = route.match(A11Y_FIXTURE_ROUTE_REGEX); + if (a11yFixtureMatch) { + const [, suite, demo] = a11yFixtureMatch; + return { path: `test/regressions/a11y/fixtures/${suite}/${demo}`, slug: suite, demo }; + } return null; } diff --git a/test/regressions/fixtures.js b/test/regressions/fixtures.js index 92c01a37098886..a4af02d4efdb18 100644 --- a/test/regressions/fixtures.js +++ b/test/regressions/fixtures.js @@ -35,6 +35,35 @@ Object.keys(importRegressionFixtures).forEach((path) => { } }, []); +// A11y-only fixtures, routed as `/a11y-{slug}/{Name}`. Each suite directory +// is named after a docs slug (lowercase) so the reporter merges its results +// into that slug's `{slug}.a11y.json` — see `parseRoute` in `demoMeta.ts`. +// They live outside `./fixtures/` on purpose: a lowercase slug directory next +// to a PascalCase screenshot suite (`rating/` next to `Rating/`) would fold +// into one directory on case-insensitive file systems (macOS default). +const importA11yFixtures = import.meta.glob(['./a11y/fixtures/**/*.{js,ts,tsx}'], { + import: 'default', + eager: true, +}); + +const a11yFixtures = []; + +Object.keys(importA11yFixtures).forEach((path) => { + const [suite, name] = path + .replace('./a11y/fixtures/', '') + .replace(/\.\w+$/, '') + .split('/'); + + if (path.startsWith('./')) { + a11yFixtures.push({ + path, + suite: `a11y-${suite}`, + name, + Component: importA11yFixtures[path], + }); + } +}, []); + // Also use some of the demos to avoid code duplication. // // Two exclusion layers: @@ -202,6 +231,9 @@ Object.keys(importComposites).forEach((path) => { }); }, []); -const fixtures = regressionFixtures.concat(demoFixtures).concat(compositeFixtures); +const fixtures = regressionFixtures + .concat(a11yFixtures) + .concat(demoFixtures) + .concat(compositeFixtures); export default fixtures;