From e695caef18300a56cbff97b268580b4c7c0d4011 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 26 Jun 2026 04:55:17 +0800 Subject: [PATCH 1/5] Add tests --- .../toggle-button/StandaloneToggleButton.js | 1 + .../toggle-button/StandaloneToggleButton.tsx | 1 + .../StandaloneToggleButton.tsx.preview | 1 + .../ToggleButtonA11yColorMatrix.js | 44 +++ .../ToggleButtonA11yColorMatrix.tsx | 44 +++ .../ToggleButtonA11yColorMatrix.tsx.preview | 14 + .../ToggleButtonA11yNonNative.js | 33 ++ .../ToggleButtonA11yNonNative.tsx | 36 ++ .../ToggleButtonA11ySemanticStates.js | 40 +++ .../ToggleButtonA11ySemanticStates.tsx | 40 +++ .../ToggleButtonA11yTextSpacing.js | 31 ++ .../ToggleButtonA11yTextSpacing.tsx | 31 ++ .../toggle-button/toggle-button.a11y.json | 334 ++++++++++++++++++ test/regressions/a11y/axe.ts | 25 +- test/regressions/demoMeta.test.ts | 26 ++ test/regressions/demoMeta.ts | 32 ++ test/regressions/index.test.js | 1 + 17 files changed, 723 insertions(+), 11 deletions(-) create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.js create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix.tsx.preview create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.js create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11yNonNative.tsx create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.js create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates.tsx create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.js create mode 100644 docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing.tsx create mode 100644 docs/data/material/components/toggle-button/toggle-button.a11y.json 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..1a61373521f1e8 --- /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..1a61373521f1e8 --- /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/test/regressions/a11y/axe.ts b/test/regressions/a11y/axe.ts index 35ff72b0a8bf08..7581807f8298c9 100644 --- a/test/regressions/a11y/axe.ts +++ b/test/regressions/a11y/axe.ts @@ -56,6 +56,11 @@ function formatResults(results: AxeResults['violations']) { interface RecordA11yOptions { slug: string; demo: string; + /** + * `visual` asserts only rules that need real rendered CSS. `all` asserts + * every axe violation/incomplete not listed in `skipAssertions`. + */ + assertions?: 'visual' | 'all'; /** * Rule ids whose violations are recorded but not asserted on. The rule * still runs and still lands in the results JSON — only the test-failing @@ -74,7 +79,7 @@ interface RecordA11yOptions { export function recordA11y( ctx: TestContext, results: AxeResults, - { slug, demo, skipAssertions = [] }: RecordA11yOptions, + { slug, demo, assertions = 'visual', skipAssertions = [] }: RecordA11yOptions, ): void { const rules: Record = {}; const buckets: ReadonlyArray<[AxeResults['passes'], RuleStatus]> = [ @@ -100,22 +105,20 @@ export function recordA11y( (ctx.task.meta as { a11y?: A11yMeta }).a11y = meta; const skip = new Set(skipAssertions); - const visualViolations = results.violations.filter( - (v) => VISUAL_RULES.includes(v.id) && !skip.has(v.id), - ); - const visualIncomplete = results.incomplete.filter( - (v) => VISUAL_RULES.includes(v.id) && !skip.has(v.id), - ); + const shouldAssert = (ruleId: string) => + !skip.has(ruleId) && (assertions === 'all' || VISUAL_RULES.includes(ruleId)); + const assertedViolations = results.violations.filter((v) => shouldAssert(v.id)); + const assertedIncomplete = results.incomplete.filter((v) => shouldAssert(v.id)); const failures: string[] = []; - if (visualViolations.length > 0) { + if (assertedViolations.length > 0) { failures.push( - `${visualViolations.length} axe violation(s):\n\n${formatResults(visualViolations)}`, + `${assertedViolations.length} axe violation(s):\n\n${formatResults(assertedViolations)}`, ); } - if (visualIncomplete.length > 0) { + if (assertedIncomplete.length > 0) { failures.push( - `${visualIncomplete.length} axe incomplete (needs review):\n\n${formatResults(visualIncomplete)}`, + `${assertedIncomplete.length} axe incomplete (needs review):\n\n${formatResults(assertedIncomplete)}`, ); } if (failures.length > 0) { diff --git a/test/regressions/demoMeta.test.ts b/test/regressions/demoMeta.test.ts index e751a21e83723c..680cc011aef85e 100644 --- a/test/regressions/demoMeta.test.ts +++ b/test/regressions/demoMeta.test.ts @@ -49,6 +49,28 @@ describe('getConfig', () => { expect( getConfig(A11Y_RULES, 'docs/data/material/components/buttons/ColorButtons'), ).to.deep.include({ enabled: true }); + expect( + getConfig(A11Y_RULES, 'docs/data/material/components/toggle-button/ToggleButtons'), + ).to.deep.include({ enabled: true, assertions: 'all' }); + expect( + getConfig( + A11Y_RULES, + 'docs/data/material/components/toggle-button/ToggleButtonA11ySemanticStates', + ), + ).to.deep.include({ enabled: true, assertions: 'all' }); + }); + + it('allows a known Toggle Button color-contrast fixture to record failures without asserting them', () => { + expect( + getConfig( + A11Y_RULES, + 'docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix', + ), + ).to.deep.include({ + enabled: true, + assertions: 'all', + skipAssertions: ['color-contrast'], + }); }); it('returns undefined for a demo outside a brace-glob enrolment', () => { @@ -56,6 +78,10 @@ describe('getConfig', () => { expect(getConfig(A11Y_RULES, 'docs/data/material/components/buttons/DisabledButtons')).to.equal( undefined, ); + // StandaloneToggleButton is a docs demo that is not enrolled for axe assertions. + expect( + getConfig(A11Y_RULES, 'docs/data/material/components/toggle-button/StandaloneToggleButton'), + ).to.equal(undefined); }); it('honours last-match-wins when multiple rules apply', () => { diff --git a/test/regressions/demoMeta.ts b/test/regressions/demoMeta.ts index 74486e2baad307..4aacdd790f9e42 100644 --- a/test/regressions/demoMeta.ts +++ b/test/regressions/demoMeta.ts @@ -37,6 +37,11 @@ export interface A11yRule { /** Minimatch glob against `docs/data/material/components/{slug}/{Demo}`. */ test: string; enabled?: boolean; + /** + * `visual` asserts rules that depend on rendered CSS. `all` asserts every + * axe violation/incomplete that is not listed in `skipAssertions`. + */ + assertions?: 'visual' | 'all'; /** Axe rule IDs recorded into results JSON but not asserted on. */ skipAssertions?: string[]; } @@ -143,6 +148,22 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [ viewportWidth: 1440, waitForSelector: '.MuiDataGrid-row:not(.MuiDataGrid-rowSkeleton) .MuiDataGrid-cell', }, + { test: 'docs/data/material/components/toggle-button/ToggleButtonA11y*', enabled: false }, // A11y-only coverage fixtures + { + test: 'docs/data/material/components/toggle-button/ToggleButtonA11yTextSpacing', + enabled: true, + }, // Visual regression for text spacing (1.4.12); adds no unique axe coverage +]; + +// toggle-button docs demos enrolled for axe assertions; the remaining demos add +// no axe coverage beyond the fixtures below. +const TOGGLE_BUTTON_A11Y_DEMOS = [ + 'ToggleButtons', + 'ToggleButtonsMultiple', + 'VerticalToggleButtons', + 'ToggleButtonA11yNonNative', + 'ToggleButtonA11ySemanticStates', + 'ToggleButtonA11yTextSpacing', ]; /** @@ -154,6 +175,17 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [ */ export const A11Y_RULES: A11yRule[] = [ { test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true }, + { + test: `docs/data/material/components/toggle-button/{${TOGGLE_BUTTON_A11Y_DEMOS.join(',')}}`, + enabled: true, + assertions: 'all', + }, + { + test: 'docs/data/material/components/toggle-button/ToggleButtonA11yColorMatrix', + enabled: true, + assertions: 'all', + skipAssertions: ['color-contrast'], + }, ]; export interface ParsedRoute { diff --git a/test/regressions/index.test.js b/test/regressions/index.test.js index 0b25c2ffb9dcb7..75212082661551 100644 --- a/test/regressions/index.test.js +++ b/test/regressions/index.test.js @@ -219,6 +219,7 @@ async function main() { recordA11y({ task }, results, { slug: parsed.slug, demo: parsed.demo, + assertions: a11yRule.assertions, skipAssertions: a11yRule.skipAssertions, }); } From b01cfdc4e209dd9d572cb06da3bf07d1fdd97db9 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 26 Jun 2026 09:45:58 +0800 Subject: [PATCH 2/5] docs --- .../src/ToggleButton/accessibility.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 packages/mui-material/src/ToggleButton/accessibility.md diff --git a/packages/mui-material/src/ToggleButton/accessibility.md b/packages/mui-material/src/ToggleButton/accessibility.md new file mode 100644 index 00000000000000..491df193ee5f39 --- /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 | 17/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.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. + +#### 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 + +`🚩 Unverified` · `⚠️ 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). + +**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 + +`🚩 Unverified` · `✅ 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. +- 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 `