From 621aa782ffecd4adb4e86058728e0dc0cd64eb49 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Tue, 11 Aug 2026 14:02:21 +0200 Subject: [PATCH 1/5] [test][radio] Add axe tests and WCAG conformance reports --- .../radio-buttons/ColorRadioButtons.js | 2 +- .../radio-buttons/ColorRadioButtons.tsx | 2 +- .../radio-buttons/SizeRadioButtons.js | 2 +- .../radio-buttons/SizeRadioButtons.tsx | 2 +- .../radio-buttons/radio-buttons.a11y.json | 514 ++++++++++++++++++ packages/mui-material/src/Radio/Radio.test.js | 133 ++++- .../mui-material/src/Radio/accessibility.md | 335 ++++++++++++ .../src/RadioGroup/RadioGroup.test.js | 121 ++++- .../src/RadioGroup/accessibility.md | 177 ++++++ test/regressions/demoMeta.test.ts | 19 + test/regressions/demoMeta.ts | 19 + 11 files changed, 1299 insertions(+), 27 deletions(-) create mode 100644 docs/data/material/components/radio-buttons/radio-buttons.a11y.json create mode 100644 packages/mui-material/src/Radio/accessibility.md create mode 100644 packages/mui-material/src/RadioGroup/accessibility.md diff --git a/docs/data/material/components/radio-buttons/ColorRadioButtons.js b/docs/data/material/components/radio-buttons/ColorRadioButtons.js index 0c18596d524934..f4b4c07df32e6a 100644 --- a/docs/data/material/components/radio-buttons/ColorRadioButtons.js +++ b/docs/data/material/components/radio-buttons/ColorRadioButtons.js @@ -14,7 +14,7 @@ export default function ColorRadioButtons() { onChange: handleChange, value: item, name: 'color-radio-button-demo', - inputProps: { 'aria-label': item }, + slotProps: { input: { 'aria-label': item } }, }); return ( diff --git a/docs/data/material/components/radio-buttons/ColorRadioButtons.tsx b/docs/data/material/components/radio-buttons/ColorRadioButtons.tsx index f01f5dd5ec82e5..7891b0048a5b4e 100644 --- a/docs/data/material/components/radio-buttons/ColorRadioButtons.tsx +++ b/docs/data/material/components/radio-buttons/ColorRadioButtons.tsx @@ -14,7 +14,7 @@ export default function ColorRadioButtons() { onChange: handleChange, value: item, name: 'color-radio-button-demo', - inputProps: { 'aria-label': item }, + slotProps: { input: { 'aria-label': item } }, }); return ( diff --git a/docs/data/material/components/radio-buttons/SizeRadioButtons.js b/docs/data/material/components/radio-buttons/SizeRadioButtons.js index 02a9ca4f90498c..bc310aab35d71c 100644 --- a/docs/data/material/components/radio-buttons/SizeRadioButtons.js +++ b/docs/data/material/components/radio-buttons/SizeRadioButtons.js @@ -12,7 +12,7 @@ export default function SizeRadioButtons() { onChange: handleChange, value: item, name: 'size-radio-button-demo', - inputProps: { 'aria-label': item }, + slotProps: { input: { 'aria-label': item } }, }); return ( diff --git a/docs/data/material/components/radio-buttons/SizeRadioButtons.tsx b/docs/data/material/components/radio-buttons/SizeRadioButtons.tsx index 2f39da5d669e17..a76bd6e4778ea1 100644 --- a/docs/data/material/components/radio-buttons/SizeRadioButtons.tsx +++ b/docs/data/material/components/radio-buttons/SizeRadioButtons.tsx @@ -12,7 +12,7 @@ export default function SizeRadioButtons() { onChange: handleChange, value: item, name: 'size-radio-button-demo', - inputProps: { 'aria-label': item }, + slotProps: { input: { 'aria-label': item } }, }); return ( diff --git a/docs/data/material/components/radio-buttons/radio-buttons.a11y.json b/docs/data/material/components/radio-buttons/radio-buttons.a11y.json new file mode 100644 index 00000000000000..f635722a0e9117 --- /dev/null +++ b/docs/data/material/components/radio-buttons/radio-buttons.a11y.json @@ -0,0 +1,514 @@ +{ + "ColorRadioButtons": { + "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"] + }, + "form-field-multiple-labels": { + "status": "pass", + "tags": ["wcag2a"] + }, + "label": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "ControlledRadioButtonsGroup": { + "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"] + }, + "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"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "CustomizedRadios": { + "rules": { + "aria-allowed-attr": { + "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"] + }, + "duplicate-id-aria": { + "status": "pass", + "tags": ["wcag2a"] + }, + "form-field-multiple-labels": { + "status": "pass", + "tags": ["wcag2a"] + }, + "label": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "ErrorRadios": { + "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"] + }, + "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"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "RadioButtons": { + "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"] + }, + "form-field-multiple-labels": { + "status": "pass", + "tags": ["wcag2a"] + }, + "label": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "RadioButtonsGroup": { + "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"] + }, + "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"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "RowRadioButtonsGroup": { + "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"] + }, + "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"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "SizeRadioButtons": { + "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"] + }, + "form-field-multiple-labels": { + "status": "pass", + "tags": ["wcag2a"] + }, + "label": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + }, + "UseRadioGroup": { + "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"] + }, + "color-contrast": { + "status": "pass", + "tags": ["wcag2aa"] + }, + "form-field-multiple-labels": { + "status": "pass", + "tags": ["wcag2a"] + }, + "label": { + "status": "pass", + "tags": ["wcag2a"] + }, + "nested-interactive": { + "status": "pass", + "tags": ["wcag2a"] + }, + "target-size": { + "status": "pass", + "tags": ["wcag22aa"] + } + } + } +} diff --git a/packages/mui-material/src/Radio/Radio.test.js b/packages/mui-material/src/Radio/Radio.test.js index 1f35c4a4b3ee75..56f4d9eaf66676 100644 --- a/packages/mui-material/src/Radio/Radio.test.js +++ b/packages/mui-material/src/Radio/Radio.test.js @@ -1,7 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; +import * as React from 'react'; +import { spy } from 'sinon'; +import { act, createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; import Radio, { radioClasses as classes } from '@mui/material/Radio'; import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; import ButtonBase from '@mui/material/ButtonBase'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import switchBaseClasses from '../internal/switchBaseClasses'; @@ -195,4 +198,132 @@ describe('', () => { expect(radio.querySelector('.touch-ripple')).not.to.equal(null); }); }); + + describe('WCAG 2.2 conformance', () => { + it('2.1.1 Keyboard: Space selects the focused radio', async () => { + const handleChange = spy(); + const { user } = render(); + const radio = screen.getByRole('radio'); + + await act(async () => { + radio.focus(); + }); + + await user.keyboard('[Space]'); + expect(radio).to.have.property('checked', true); + expect(handleChange.callCount).to.equal(1); + }); + + it('2.1.2 No Keyboard Trap: keyboard focus can enter and leave the radio', async () => { + const { user } = render( + + + + + , + ); + const before = screen.getByRole('button', { name: 'before' }); + const after = screen.getByRole('button', { name: 'after' }); + const radio = screen.getByRole('radio'); + + before.focus(); + await user.tab(); + expect(document.activeElement).to.equal(radio); + await user.tab(); + expect(document.activeElement).to.equal(after); + await user.tab({ shift: true }); + expect(document.activeElement).to.equal(radio); + }); + + it('2.4.3 Focus Order: is a single tab stop in DOM order', async () => { + const { user } = render( + + + + + , + ); + const first = screen.getByRole('button', { name: 'first' }); + const radio = screen.getByRole('radio'); + const last = screen.getByRole('button', { name: 'last' }); + + first.focus(); + await user.tab(); + expect(document.activeElement).to.equal(radio); + await user.tab(); + expect(document.activeElement).to.equal(last); + }); + + it('2.5.2 Pointer Cancellation: activates on the pointer up-event, not the down-event', async () => { + const handleChange = spy(); + const { user } = render(); + const radio = screen.getByRole('radio'); + + // Press without releasing: the down-event must not select. + await user.pointer({ keys: '[MouseLeft>]', target: radio }); + expect(radio).to.have.property('checked', false); + expect(handleChange.callCount).to.equal(0); + + // Releasing over the target completes the activation. + await user.pointer({ keys: '[/MouseLeft]', target: radio }); + expect(radio).to.have.property('checked', true); + expect(handleChange.callCount).to.equal(1); + }); + + it('2.5.3 Label in Name: the accessible name matches the visible label', () => { + render(} label="Express delivery" />); + // getByRole with `name` only resolves if the accessible name matches the visible label. + expect(screen.getByRole('radio', { name: 'Express delivery' })).to.have.property( + 'checked', + false, + ); + }); + + it('3.2.1 On Focus: moving focus to the radio changes no context or state', async () => { + const handleChange = spy(); + const { user } = render(); + const radio = screen.getByRole('radio'); + + await user.tab(); + + expect(document.activeElement).to.equal(radio); + expect(radio).to.have.property('checked', false); + expect(handleChange.callCount).to.equal(0); + }); + + it('3.2.2 On Input: selecting changes only the value and fires onChange', async () => { + const handleChange = spy(); + const { user } = render(); + const radio = screen.getByRole('radio'); + + await user.click(radio); + + expect(radio).to.have.property('checked', true); + expect(handleChange.callCount).to.equal(1); + }); + + describe('4.1.2 Name, Role, Value', () => { + it('exposes the radio role, its value, and its accessible name', () => { + render(} label="Option A" />); + const radio = screen.getByRole('radio', { name: 'Option A' }); + + expect(radio).to.have.property('value', 'a'); + }); + + it('reflects the checked state', async () => { + const { user } = render(); + const radio = screen.getByRole('radio'); + expect(radio).to.have.property('checked', false); + + await user.click(radio); + expect(radio).to.have.property('checked', true); + }); + + it('reflects the disabled state', () => { + render(); + + expect(screen.getByRole('radio')).to.have.property('disabled', true); + }); + }); + }); }); diff --git a/packages/mui-material/src/Radio/accessibility.md b/packages/mui-material/src/Radio/accessibility.md new file mode 100644 index 00000000000000..e6abd95778d68d --- /dev/null +++ b/packages/mui-material/src/Radio/accessibility.md @@ -0,0 +1,335 @@ +# Radio accessibility conformance + +Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility.md). + +| Result | Count | +| :-------------------- | :---- | +| ✅ Supports | 23 | +| ⚠️ Partially Supports | 2 | +| ❌ Does Not Support | 0 | +| ➖ Not Applicable | 30 | +| 🚩 Flagged | 3/25 | + +## Known gaps + +- ⚠️ **1.4.11 Non-text Contrast.** The default dot and circle clear 3:1 (`warning` is the tightest at 3.11:1), but the keyboard focus indicator is untested and `disableRipple`/`disableFocusRipple` or custom icons can drop below 3:1 (the `CustomizedRadios` unchecked circle is about 1.1:1 against the page). +- ⚠️ **2.4.7 Focus Visible.** `disableRipple`/`disableFocusRipple` removes the focus ripple, leaving none unless the author adds `.Mui-focusVisible` styling. + +## Success criteria + +### 🔍 Manual + +#### 1.3.2 Meaningful Sequence · A + +`✅ Supports` · `○ Author` + +- The radio is one control: the hidden `` followed by the `aria-hidden` dot and circle, with `FormControlLabel` placing the label and control in source order, so the reading order matches the visual order. +- Order carries meaning only across several controls, which the surrounding layout sets. `labelPlacement="bottom"` flips the label and control vertically, so confirm the reading order of a group like `FormControlLabelPlacement` still matches its visual order. + +**Manual testing steps** + +1. Render a `RadioGroup` with several labeled `Radio` options, then press Tab and the arrow keys through them, noting the focus order. +2. Compare that order to the visual top-to-bottom order. +3. Watch for `labelPlacement` or flex layouts that reorder a row visually without changing the DOM. + +**Pass:** focus and reading order match the visual order. + +#### 1.3.3 Sensory Characteristics · A + +`✅ Supports` · `○ Author` + +- The component renders no instructional text of its own. +- The author is responsible for ensuring surrounding copy does not rely on sensory characteristics alone (for example "select the green option" or "the option on the right"). + +**Manual testing steps** + +1. Review the copy near a radio group for sensory-only references. +2. Check that each also names the option by its label. + +**Pass:** no instruction relies on color, shape, size, or position without naming the control. + +#### 1.4.5 Images of Text · AA + +`✅ Supports` · `○ Author` + +- Labels and helper text render as live DOM text; the dot and circle are vector graphics without any textual information. +- This fails only if an author passes an image of text as the label, or supplies an `icon`/`checkedIcon` that is an image of text. Use real text unless it is a logo. + +**Manual testing steps** + +1. Inspect the label and helper nodes and confirm they are selectable live text, not `` or background images of text. +2. Confirm no label, `icon`, or `checkedIcon` uses an image of text. + +**Pass:** labels and helper text are live text. + +#### 2.4.11 Focus Not Obscured (Minimum) · AA + +`✅ Supports` · `○ Author` + +- The radio is an ordinary focusable control 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 radio 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 with a sticky header, footer, or banner, scroll so a radio sits under the sticky element. +2. Press Tab to move focus onto it. + +**Pass:** at least part of the focused radio stays visible, never fully covered. + +#### 3.2.4 Consistent Identification · AA + +`✅ Supports` · `○ Author` + +- The component exposes whatever name the author supplies. +- Consistency is a cross-page property. Confirm that radios with the same function share a label, and that one label is not reused for different functions. + +**Manual testing steps** + +1. List the radio options that do the same job across the product. +2. Compare their labels. + +**Pass:** the same function uses the same label, and no label is reused for different functions. + +#### 3.3.2 Labels or Instructions · A + +`✅ Supports` · `○ Author` + +- A visible label or instruction is the author's content to supply; the component renders a real `