diff --git a/docs/data/material/components/toggle-button/StandaloneToggleButton.js b/docs/data/material/components/toggle-button/StandaloneToggleButton.js index ab6f99ffd379ef..732c7777d736d4 100644 --- a/docs/data/material/components/toggle-button/StandaloneToggleButton.js +++ b/docs/data/material/components/toggle-button/StandaloneToggleButton.js @@ -10,6 +10,7 @@ export default function StandaloneToggleButton() { value="check" selected={selected} onChange={() => setSelected((prevSelected) => !prevSelected)} + aria-label="mark as done" > diff --git a/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx b/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx index ab6f99ffd379ef..732c7777d736d4 100644 --- a/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx +++ b/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx @@ -10,6 +10,7 @@ export default function StandaloneToggleButton() { value="check" selected={selected} onChange={() => setSelected((prevSelected) => !prevSelected)} + aria-label="mark as done" > diff --git a/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx.preview b/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx.preview index b59402da9c7a3d..ecbbe1074cb76f 100644 --- a/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx.preview +++ b/docs/data/material/components/toggle-button/StandaloneToggleButton.tsx.preview @@ -2,6 +2,7 @@ value="check" selected={selected} onChange={() => setSelected((prevSelected) => !prevSelected)} + aria-label="mark as done" > \ No newline at end of file diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.js b/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.js new file mode 100644 index 00000000000000..457575b40e13cd --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.js @@ -0,0 +1,44 @@ +import Box from '@mui/material/Box'; +import ToggleButton from '@mui/material/ToggleButton'; + +const colors = [ + 'standard', + 'primary', + 'secondary', + 'error', + 'info', + 'success', + 'warning', +]; + +export default function ToggleButtonA11yColorMatrix() { + return ( + div': { + display: 'flex', + flexWrap: 'wrap', + gap: 1, + }, + }} + > + {[false, true].map((selected) => ( +
+ {colors.map((color) => ( + + {color} + + ))} +
+ ))} +
+ ); +} diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx b/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx new file mode 100644 index 00000000000000..2a87b54698a42b --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx @@ -0,0 +1,44 @@ +import Box from '@mui/material/Box'; +import ToggleButton from '@mui/material/ToggleButton'; + +const colors = [ + 'standard', + 'primary', + 'secondary', + 'error', + 'info', + 'success', + 'warning', +] as const; + +export default function ToggleButtonA11yColorMatrix() { + return ( + div': { + display: 'flex', + flexWrap: 'wrap', + gap: 1, + }, + }} + > + {[false, true].map((selected) => ( +
+ {colors.map((color) => ( + + {color} + + ))} +
+ ))} +
+ ); +} diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx.preview b/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx.preview new file mode 100644 index 00000000000000..55bff7bb5e5745 --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx.preview @@ -0,0 +1,14 @@ +{[false, true].map((selected) => ( +
+ {colors.map((color) => ( + + {color} + + ))} +
+))} \ No newline at end of file diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.js b/docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.js new file mode 100644 index 00000000000000..bd686cbd95a1ed --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.js @@ -0,0 +1,33 @@ +import * as React from 'react'; +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; + +const CustomDivButton = React.forwardRef(function CustomDivButton(props, ref) { + return
; +}); + +export default function ToggleButtonA11yNonNative() { + return ( + + + Non-native pressed + + + Non-native + + + Disabled non-native + + + ); +} diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.tsx b/docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.tsx new file mode 100644 index 00000000000000..ab0f29bf6ab046 --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; + +const CustomDivButton = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(function CustomDivButton(props, ref) { + return
; +}); + +export default function ToggleButtonA11yNonNative() { + return ( + + + Non-native pressed + + + Non-native + + + Disabled non-native + + + ); +} diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.js b/docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.js new file mode 100644 index 00000000000000..7aab07f583499d --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.js @@ -0,0 +1,40 @@ +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; +import FormatBoldIcon from '@mui/icons-material/FormatBold'; + +export default function ToggleButtonA11ySemanticStates() { + return ( + + + + Not pressed + + + Pressed + + + Disabled + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.tsx b/docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.tsx new file mode 100644 index 00000000000000..7aab07f583499d --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.tsx @@ -0,0 +1,40 @@ +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; +import FormatBoldIcon from '@mui/icons-material/FormatBold'; + +export default function ToggleButtonA11ySemanticStates() { + return ( + + + + Not pressed + + + Pressed + + + Disabled + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.js b/docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.js new file mode 100644 index 00000000000000..52968c8cf35b98 --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.js @@ -0,0 +1,31 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; + +export default function ToggleButtonA11yTextSpacing() { + return ( + + + Review accessibility settings before continuing + + + + Align a longer label to the left + + + Align a longer label to the right + + + + ); +} diff --git a/docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.tsx b/docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.tsx new file mode 100644 index 00000000000000..52968c8cf35b98 --- /dev/null +++ b/docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.tsx @@ -0,0 +1,31 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; + +export default function ToggleButtonA11yTextSpacing() { + return ( + + + Review accessibility settings before continuing + + + + Align a longer label to the left + + + Align a longer label to the right + + + + ); +} diff --git a/docs/data/material/components/toggle-button/toggle-button.a11y.json b/docs/data/material/components/toggle-button/toggle-button.a11y.json new file mode 100644 index 00000000000000..65d9bed02959a6 --- /dev/null +++ b/docs/data/material/components/toggle-button/toggle-button.a11y.json @@ -0,0 +1,334 @@ +{ + "ToggleButtonA11yColorMatrix": { + "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"] + } + } + }, + "ToggleButtonA11yNonNative": { + "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"] + } + } + }, + "ToggleButtonA11ySemanticStates": { + "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"] + } + } + }, + "ToggleButtonA11yTextSpacing": { + "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"] + } + } + }, + "ToggleButtons": { + "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-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"] + }, + "button-name": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "ToggleButtonsMultiple": { + "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-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"] + }, + "button-name": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "VerticalToggleButtons": { + "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-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"] + }, + "button-name": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + } +} diff --git a/packages/mui-material/src/ToggleButton/ToggleButton.test.js b/packages/mui-material/src/ToggleButton/ToggleButton.test.js index b3c7915f282ca8..347bd9712c34f8 100644 --- a/packages/mui-material/src/ToggleButton/ToggleButton.test.js +++ b/packages/mui-material/src/ToggleButton/ToggleButton.test.js @@ -1,9 +1,16 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; -import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; +import { + createRenderer, + screen, + isJsdom, + fireEvent, + focusVisible, + simulatePointerDevice, +} from '@mui/internal-test-utils'; import ToggleButton, { toggleButtonClasses as classes } from '@mui/material/ToggleButton'; -import ButtonBase from '@mui/material/ButtonBase'; +import ButtonBase, { buttonBaseClasses } from '@mui/material/ButtonBase'; import describeConformance from '../../test/describeConformance'; describe('', () => { @@ -150,6 +157,69 @@ describe('', () => { }); }); + describe('accessibility', () => { + it('reflects `selected` as `aria-pressed` (WCAG 4.1.2)', () => { + const { setProps } = render( + + Bold + , + ); + const button = screen.getByRole('button'); + expect(button).to.have.attribute('aria-pressed', 'false'); + + setProps({ selected: true }); + expect(button).to.have.attribute('aria-pressed', 'true'); + }); + + it('keeps the visible label within the accessible name (WCAG 2.5.3)', () => { + render( + + Bold + , + ); + + const button = screen.getByRole('button'); + expect(button.getAttribute('aria-label')).to.contain(button.textContent); + }); + + it('activates on click, not on `mouseDown` alone (WCAG 2.5.2)', () => { + const handleChange = spy(); + render( + + Hello + , + ); + const button = screen.getByRole('button'); + + fireEvent.mouseDown(button); + expect(handleChange.callCount).to.equal(0); + + button.click(); + expect(handleChange.callCount).to.equal(1); + }); + + // `:focus-visible` is only reliable in a real browser, so this runs there (not jsdom). + it.skipIf(isJsdom())( + 'leaves no focus indicator (the ripple) under `disableRipple` (WCAG 2.4.7)', + () => { + render( + + Hello + , + ); + const button = screen.getByRole('button'); + + simulatePointerDevice(); + focusVisible(button); + + // Keyboard focus is detected, but `disableRipple` leaves no visible indicator: + // the ripple is the only focus style the component provides. + expect(button).to.have.class(buttonBaseClasses.focusVisible); + expect(button.querySelector('.MuiTouchRipple-root')).to.equal(null); + }, + ); + }); + describe.skipIf(!isJsdom())('server-side', () => { it('should server-side render', () => { const { container } = renderToString(Hello World); diff --git a/packages/mui-material/src/ToggleButton/accessibility.md b/packages/mui-material/src/ToggleButton/accessibility.md new file mode 100644 index 00000000000000..860bef82866e3d --- /dev/null +++ b/packages/mui-material/src/ToggleButton/accessibility.md @@ -0,0 +1,361 @@ +# Toggle Button accessibility conformance + +Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility.md). + +| Result | Count | +| :-------------------- | :---- | +| ✅ Supports | 20 | +| ⚠️ Partially Supports | 4 | +| ❌ Does Not Support | 0 | +| ➖ Not Applicable | 31 | +| 🚩 Unverified | 14/24 | + +## Known gaps + +- ⚠️ **1.4.1 Use of Color.** For the color variants (`primary`, `error`, `info`, `success`), the selected and unselected labels are near-identical in grayscale lightness, so the pressed state is conveyed almost entirely by hue. +- ⚠️ **1.4.3 Contrast (Minimum).** When selected, the `primary`, `error`, `info`, and `warning` labels (their `color.main` text over the tinted selected background) fall short of 4.5:1. +- ⚠️ **1.4.11 Non-text Contrast.** The focus indicator is the ripple; `disableRipple`/`disableFocusRipple` remove it, leaving no focus indicator. The selected-state fill is untested for 3:1. +- ⚠️ **2.4.7 Focus Visible.** `disableRipple`/`disableFocusRipple` remove the only keyboard focus indicator; the Toggle Button has no box-shadow fallback. + +## Success criteria + +### 🔍 Manual + +#### 1.3.2 Meaningful Sequence · A + +`🚩 Unverified` · `✅ Supports` · `○ Author` + +- The Toggle Button is one control. Its children render in DOM order; a decorative MUI icon is `aria-hidden`, leaving the label (text or `aria-label`) as the exposed content. +- Order carries meaning only across several toggles, which the surrounding layout (often a `ToggleButtonGroup`) sets. Confirm that reading order matches the visual order. + +**Manual testing steps** + +1. Open a demo with a row of toggles (`ToggleButtons`). +2. Press Tab repeatedly and note the order the toggles receive focus. +3. Compare that order to the visual left-to-right order. + +**Pass:** focus order matches the visual order. Watch for layouts that reorder toggles visually without changing the DOM. + +#### 1.3.3 Sensory Characteristics · A + +`🚩 Unverified` · `✅ Supports` · `○ Author` + +- A toggle 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, "the highlighted button"). + +**Manual testing steps** + +1. Find any product copy that tells users to operate a toggle. +2. Check that it names the toggle by its label, not only by color, shape, size, or position. + +**Pass:** no instruction relies on "the highlighted one" or "the button on the left" without naming it. + +#### 1.4.4 Resize Text · AA + +`🚩 Unverified` · `✅ Supports` · `◐ Shared` + +- Typography is set in rem and em, so the label and toggle scale with browser zoom or font size rather than staying pixel-fixed. +- A fixed-pixel container in the surrounding layout could clip at 200%. + +**Manual testing steps** + +1. Open the Toggle Button demos and set browser zoom to 200% (Ctrl or Cmd and +). +2. Confirm labels are fully visible and toggles still work. + +**Pass:** nothing is clipped or cut off at 200%. + +#### 1.4.5 Images of Text · AA + +`🚩 Unverified` · `✅ Supports` · `○ Author` + +- A text label is live CSS text; the component never renders it as an image. +- This fails only if an image of text, or an icon font spelling words, is passed as the children. Use real text or an icon with an `aria-label`. + +**Manual testing steps** + +1. Open a toggle with a text label and try to select the text with the mouse, or zoom in. +2. Confirm it behaves like real text (selectable, stays crisp), not a picture. + +**Pass:** labels are live text. No toggle uses an image of text. + +#### 1.4.10 Reflow · AA + +`🚩 Unverified` · `✅ Supports` · `◐ Shared` + +- A toggle sizes to its content, so it reflows on its own. Horizontal overflow at 320 CSS pixels comes from the surrounding layout, such as a wide `ToggleButtonGroup` that does not wrap. + +**Manual testing steps** + +1. Open the Toggle Button demos and set the window, or the DevTools device toolbar, to 320 CSS pixels wide. +2. Confirm there is no sideways scrolling and all toggles are reachable. + +**Pass:** content reflows with no horizontal scroll. + +#### 2.4.11 Focus Not Obscured (Minimum) · AA + +`🚩 Unverified` · `✅ Supports` · `○ Author` + +- The Toggle 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 a toggle 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 toggle near it. +2. Scroll so the toggle sits under the sticky element, then Tab to it again. + +**Pass:** at least part of the focused toggle stays visible, never fully covered. + +#### 3.2.4 Consistent Identification · AA + +`🚩 Unverified` · `✅ Supports` · `○ Author` + +- The component produces one stable accessible name per set of props, the precondition for consistent identification. +- Consistency is a cross-page property. Confirm that toggles 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 toggles that do the same job across the product (for example, every "Bold"). +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. + +### 🔁 Hybrid + +#### 1.1.1 Non-text Content · A + +`✅ Supports` · `◐ Shared` + +- A MUI `SvgIcon` child defaults to `aria-hidden` + `focusable="false"`, so it does not contribute to the name; the name comes from the children text or an `aria-label`. axe-core confirms a non-empty name (`button-name` on the native-button demos, `aria-command-name` on the non-native host). +- An icon-only toggle has no accessible name unless the author sets an `aria-label`, as every icon toggle in the demos does. Whether the name conveys meaning needs an assistive-technology review. + +**Manual testing steps** + +1. Open `ToggleButtonA11ySemanticStates` with a screen reader running (NVDA with Chrome, or VoiceOver with Safari). +2. Tab to each toggle and confirm it announces the visible label or `aria-label`, and does not read out the icon. +3. For any icon-only toggle in the product, confirm it has an `aria-label` describing the action. + +**Pass:** every toggle's announced name matches its purpose, and decorative icons are silent. + +#### 1.3.1 Info and Relationships · A + +`✅ Supports` · `◐ Shared` + +- The component sets the role (`button`, or `role="button"` for a non-native host), the pressed state through `aria-pressed` (`ToggleButton.js`), and the disabled state. axe-core's ARIA rules (`aria-allowed-attr`, `aria-valid-attr-value`, and `aria-roles` where a non-default role is present) pass across the demos. +- Grouping relationships (a `role="group"` and its label) belong to `ToggleButtonGroup`, not the single toggle. Whether a runtime state change is announced needs an assistive-technology review. + +**Manual testing steps** + +1. Open `ToggleButtonA11ySemanticStates` and `ToggleButtonA11yNonNative` with a screen reader running. +2. Confirm the plain toggle announces "button", the selected one announces its pressed state, the disabled one announces its disabled state, and the custom one announces "button". + +**Pass:** role and state match the visual presentation for every variant. + +#### 1.4.1 Use of Color · A + +`🚩 Unverified` · `⚠️ Partially Supports` · `● Component` + +- A toggle's purpose comes from its label or icon, not color, so the control itself is not identified by color alone. +- The pressed state is the gap. For `standard`, selecting darkens the label from `action.active` to `text.primary`, a clear lightness change. For the color variants the selected label switches to `color.main`, which is almost the same lightness as the unselected `action.active` (in grayscale `primary` differs by about 0.0003, `error` by 0.017), and the only other cue is a roughly 1.1:1 fill, so a `primary`/`error`/`info`/`success` toggle's pressed state is conveyed almost entirely by hue. + +**Manual testing steps** + +1. Open `ColorToggleButton` or `ToggleButtonA11yColorMatrix`. +2. Turn on a grayscale view (in Chrome DevTools: Rendering tab, Emulate vision deficiencies, Achromatopsia). +3. Confirm each toggle can still be told apart, and that a selected toggle is still distinguishable from an unselected one. + +**Pass:** the control's meaning survives without color. The `standard` selected state stays distinguishable; the color variants do not, since their pressed cue is hue-dominant. + +#### 1.4.3 Contrast (Minimum) · AA + +`⚠️ Partially Supports` · `● Component` + +- When selected, a toggle uses `color.main` as the label color over a `color.main`-tinted background (`ToggleButton.js`). The `primary`, `error`, `info`, and `warning` labels fall short of 4.5:1; `ToggleButtonA11yColorMatrix` records the `color-contrast` failure in [`toggle-button.a11y.json`](../../../../docs/data/material/components/toggle-button/toggle-button.a11y.json) (rule-level pass/fail only). The unselected label (`action.active`) and the `standard` selected label pass. +- axe-core `color-contrast` checks the resting state. The `:hover` colors and custom palettes need a visual check. Disabled toggles are exempt. + +**Manual testing steps** + +1. Open `ToggleButtonA11yColorMatrix`. With a contrast checker (the color picker in browser DevTools shows a ratio), check each selected label against its background. +2. Repeat with the pointer hovering, since `:hover` changes 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 and hover states. Disabled is exempt; selected `primary`, `error`, `info`, and `warning` are the known failures. + +#### 1.4.11 Non-text Contrast · AA + +`🚩 Unverified` · `⚠️ Partially Supports` · `● Component` + +- The label or icon identifies the control, so WCAG does not require the `divider` border to reach 3:1. The pieces that do need 3:1 are the focus indicator and the visual cue that marks the pressed state. +- The focus indicator is the ripple; `disableRipple`/`disableFocusRipple` remove it, so it can be absent entirely. The selected-state fill (`action.selectedOpacity`, 0.08) is faint and untested against the unselected state. Disabled toggles are exempt. + +**Manual testing steps** + +1. Open `ToggleButtons`. Press Tab to a toggle so its focus indicator shows, and measure the indicator against the colors next to it. +2. Measure a selected toggle's fill (and any meaningful icon) against the unselected state and the background. +3. Set `disableRipple` or `disableFocusRipple` on a toggle (no demo ships these) and repeat: the focus indicator should be gone. + +**Pass:** the focus indicator and any meaningful icon are each at least 3:1, and the pressed state stays identifiable. Disabled parts are exempt. + +#### 1.4.12 Text Spacing · AA + +`🚩 Unverified` · `✅ Supports` · `◐ Shared` + +- A text label wraps when `white-space: normal` is set and the toggle height comes from padding, not a fixed height, so the WCAG text-spacing values grow the toggle without clipping. +- No axe-core rule covers this (`avoid-inline-spacing` only inspects inline `style`, but `sx` compiles to a class); a `ToggleButtonA11yTextSpacing` screenshot guards the rendered spacing layout against regressions. Inject the spacing values through a stylesheet to check for clipping. + +**Manual testing steps** + +1. Open `ToggleButtonA11yTextSpacing`. +2. Apply the WCAG text-spacing values. The quickest way is to run this in the DevTools console: `document.head.insertAdjacentHTML('beforeend','')`. +3. Look for cut-off, clipped, or overlapping label text. + +**Pass:** all label text stays visible and the toggle still works. + +#### 2.4.6 Headings and Labels · AA + +`✅ Supports` · `◐ Shared` + +- The accessible name serves as the control's label. axe-core confirms it is present (`button-name` on the native-button demos, `aria-command-name` on the non-native host). +- Whether the label describes the action (a clear `aria-label` against a vague one) is a content decision, confirmed with a manual review. + +**Manual testing steps** + +1. Read each toggle's label, or its `aria-label`, out of context. +2. Ask whether it says what the toggle does. + +**Pass:** every label describes its action ("Bold", not "B"). + +#### 2.4.7 Focus Visible · AA + +`⚠️ Partially Supports` · `● Component` + +- Keyboard focus shows the `.Mui-focusVisible` ripple (suppressed for mouse). The component sets no other focus style. +- `disableRipple` removes every ripple and `disableFocusRipple` removes the focus ripple, so either prop leaves the toggle with no visible focus indicator (the `disableRipple` prop documents this). [`ToggleButton.test.js`](./ToggleButton.test.js) confirms a keyboard-focused toggle renders no ripple under `disableRipple`. + +**Manual testing steps** + +1. Open `ToggleButtons` and `StandaloneToggleButton`. Press Tab to move to each toggle. +2. Confirm a clear focus indicator appears, and that it looks different from the selected style. +3. Click a toggle with the mouse and confirm the indicator does not appear (it is keyboard-only). +4. Set `disableRipple` or `disableFocusRipple` on a toggle (no demo ships these) and Tab to it. + +**Pass:** every keyboard-focused toggle shows a visible indicator, including under `disableRipple` and `disableFocusRipple`. + +#### 2.5.3 Label in Name · A + +`✅ Supports` · `◐ Shared` + +- When a toggle has a visible text label, that text is the accessible name (the children become the name). An icon-only toggle has no visible text, so its `aria-label` is the name. [`ToggleButton.test.js`](./ToggleButton.test.js) confirms the accessible name contains the visible label. +- An `aria-label` that omits or reorders the visible words breaks this. Compare the visible text to the computed name. + +**Manual testing steps** + +1. Open the accessibility tree (in Chrome DevTools: Elements panel, Accessibility tab) and select a toggle with a visible text label. +2. Compare its computed Name to the words shown on screen. +3. Optional: with Voice Control (macOS) or Dragon, say "click [label]" and confirm it activates. + +**Pass:** the visible text appears in the accessible name. An `aria-label` that drops or reorders the visible words fails. + +#### 4.1.2 Name, Role, Value · A + +`✅ Supports` · `◐ Shared` + +- A native `