diff --git a/docs/data/material/components/accordion/accordion.a11y.json b/docs/data/material/components/accordion/accordion.a11y.json
new file mode 100644
index 00000000000000..bf70e41e24f8d8
--- /dev/null
+++ b/docs/data/material/components/accordion/accordion.a11y.json
@@ -0,0 +1,530 @@
+{
+ "AccordionA11yNonNative": {
+ "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-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"]
+ },
+ "avoid-inline-spacing": {
+ "status": "pass",
+ "tags": ["wcag21aa"]
+ },
+ "color-contrast": {
+ "status": "pass",
+ "tags": ["wcag2aa"]
+ },
+ "duplicate-id-aria": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "AccordionA11yTextSpacing": {
+ "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"]
+ },
+ "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"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "AccordionExpandDefault": {
+ "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"]
+ },
+ "avoid-inline-spacing": {
+ "status": "pass",
+ "tags": ["wcag21aa"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "incomplete",
+ "tags": ["wcag2aa"]
+ },
+ "duplicate-id-aria": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "AccordionExpandIcon": {
+ "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"]
+ },
+ "avoid-inline-spacing": {
+ "status": "pass",
+ "tags": ["wcag21aa"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "incomplete",
+ "tags": ["wcag2aa"]
+ },
+ "duplicate-id-aria": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "AccordionTransition": {
+ "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"]
+ },
+ "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"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "AccordionUsage": {
+ "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"]
+ },
+ "avoid-inline-spacing": {
+ "status": "pass",
+ "tags": ["wcag21aa"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "incomplete",
+ "tags": ["wcag2aa"]
+ },
+ "duplicate-id-aria": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "ControlledAccordions": {
+ "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"]
+ },
+ "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"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "CustomizedAccordions": {
+ "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"]
+ },
+ "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"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ },
+ "DisabledAccordion": {
+ "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"]
+ },
+ "avoid-inline-spacing": {
+ "status": "pass",
+ "tags": ["wcag21aa"]
+ },
+ "button-name": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "color-contrast": {
+ "status": "incomplete",
+ "tags": ["wcag2aa"]
+ },
+ "duplicate-id-aria": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "nested-interactive": {
+ "status": "pass",
+ "tags": ["wcag2a"]
+ },
+ "target-size": {
+ "status": "pass",
+ "tags": ["wcag22aa"]
+ }
+ }
+ }
+}
diff --git a/packages/mui-material/src/Accordion/Accordion.test.js b/packages/mui-material/src/Accordion/Accordion.test.js
index d8697f10726c71..382ed1b31cc77a 100644
--- a/packages/mui-material/src/Accordion/Accordion.test.js
+++ b/packages/mui-material/src/Accordion/Accordion.test.js
@@ -5,12 +5,13 @@ import { spy } from 'sinon';
import { createRenderer, fireEvent, isJsdom, reactMajor, screen } from '@mui/internal-test-utils';
import Accordion, { accordionClasses as classes } from '@mui/material/Accordion';
import Paper from '@mui/material/Paper';
-import Collapse from '@mui/material/Collapse';
+import Collapse, { collapseClasses } from '@mui/material/Collapse';
import Fade from '@mui/material/Fade';
import Slide from '@mui/material/Slide';
import Grow from '@mui/material/Grow';
import Zoom from '@mui/material/Zoom';
import AccordionSummary, { accordionSummaryClasses } from '@mui/material/AccordionSummary';
+import AccordionDetails from '@mui/material/AccordionDetails';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import describeConformance from '../../test/describeConformance';
@@ -451,4 +452,66 @@ describe('', () => {
expect(screen.getByTestId('region-slot')).to.have.attribute('role', 'list');
});
+
+ describe('WCAG 2.2 conformance', () => {
+ it('2.4.3 Focus Order: a collapsed panel is hidden, removing its content from the tab order', () => {
+ const { container } = render(
+
+ Summary
+ Details
+ ,
+ );
+
+ // Collapse applies its `hidden` class (visibility: hidden) when fully
+ // collapsed, so the panel and any focusable content it holds leave the
+ // tab order until the accordion is expanded.
+ expect(container.querySelector(`.${collapseClasses.hidden}`)).not.to.equal(null);
+ });
+
+ it('2.5.2 Pointer Cancellation: toggles on release, not on press', async () => {
+ const handleChange = spy();
+ const { user } = render(
+
+
+ Summary
+ Details
+
+
+ ,
+ );
+ const summary = screen.getByRole('button');
+
+ // Pressing and releasing away from the summary cancels the activation.
+ await user.pointer([
+ { keys: '[MouseLeft>]', target: summary },
+ { target: screen.getByTestId('outside') },
+ { keys: '[/MouseLeft]' },
+ ]);
+ expect(handleChange.callCount).to.equal(0);
+
+ await user.click(summary);
+ expect(handleChange.callCount).to.equal(1);
+ });
+
+ describe('4.1.2 Name, Role, Value', () => {
+ it('exposes the open state on the summary and names the panel region', async () => {
+ const { user } = render(
+
+
+ Summary
+
+ Details
+ ,
+ );
+ const summary = screen.getByRole('button');
+ expect(summary).to.have.attribute('aria-expanded', 'false');
+
+ await user.click(summary);
+
+ expect(summary).to.have.attribute('aria-expanded', 'true');
+ // The panel is a region named by the summary that controls it.
+ expect(screen.getByRole('region', { name: 'Summary' })).not.to.equal(null);
+ });
+ });
+ });
});
diff --git a/packages/mui-material/src/Accordion/accessibility.md b/packages/mui-material/src/Accordion/accessibility.md
new file mode 100644
index 00000000000000..b884075cf02f95
--- /dev/null
+++ b/packages/mui-material/src/Accordion/accessibility.md
@@ -0,0 +1,262 @@
+# Accordion accessibility conformance
+
+Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility.md).
+
+This doc covers the root `` together with `AccordionDetails` and `AccordionActions`, which are passive content containers. It inherits the header's item-level criteria from [AccordionSummary](../AccordionSummary/accessibility.md).
+
+| Result | Count |
+| :--------------------------------- | :---- |
+| ✅ Supports | 19 |
+| ⚠️ Partially Supports | 0 |
+| ❌ Does Not Support | 0 |
+| ➖ Not Applicable | 31 |
+| ↗ Inherited (see AccordionSummary) | 5 |
+| 🚩 Flagged | 3/19 |
+
+## Known gaps
+
+- Inherits: ⚠️ **1.4.11 Non-text Contrast** (the summary focus indicator) from [AccordionSummary](../AccordionSummary/accessibility.md).
+
+## Success criteria
+
+### 🔍 Manual
+
+#### 1.3.2 Meaningful Sequence · A
+
+`🚩` · `✅ Supports` · `◐ Shared`
+
+- The header renders before its panel in DOM order, so the reading sequence matches the visual one; the panel content is author-supplied.
+
+**Manual testing steps**
+
+1. With a screen reader running, expand an accordion and read it top to bottom.
+
+**Pass:** the header is announced before its panel content.
+
+#### 1.3.3 Sensory Characteristics · A
+
+`✅ Supports` · `○ Author`
+
+- The accordion is operated by its header label, not by a sensory cue; surrounding instructions must not rely on the chevron's shape or position alone.
+
+**Manual testing steps**
+
+1. Find product copy that tells users to expand a section and check it names the section by its heading.
+
+**Pass:** no instruction depends on shape, color, or position alone.
+
+#### 1.4.1 Use of Color · A
+
+`🚩` · `✅ Supports` · `◐ Shared`
+
+- The open and closed states are shown by `aria-expanded` and the rotated chevron, not by color alone.
+
+**Manual testing steps**
+
+1. In a UI with accordions, turn on a grayscale view and expand or collapse a panel.
+
+**Pass:** the state stays distinguishable without color.
+
+#### 1.4.5 Images of Text · AA
+
+`✅ Supports` · `○ Author`
+
+- The header and panel render real text.
+
+**Manual testing steps**
+
+1. Confirm the header or panel text is real text and not an image.
+
+**Pass:** no header or panel renders its text as an image.
+
+#### 2.4.11 Focus Not Obscured (Minimum) · AA
+
+`✅ Supports` · `○ Author`
+
+- The accordion never hides its own focused header; full obscuring comes from sticky headers or overlays in the surrounding layout.
+
+**Manual testing steps**
+
+1. In a page with a sticky header, Tab to a header near it, scroll it under the sticky element, and Tab back.
+
+**Pass:** at least part of the focused header stays visible.
+
+#### 3.2.4 Consistent Identification · AA
+
+`✅ Supports` · `○ Author`
+
+- The component produces one stable name and structure per set of props; using the same header for the same purpose across pages is an authoring concern.
+
+**Manual testing steps**
+
+1. Compare accordions with same purpose across the product.
+
+**Pass:** consistently uses the same text and icon.
+
+### 🔁 Hybrid
+
+#### 1.3.1 Info and Relationships · A
+
+`✅ Supports` · `◐ Shared`
+
+- The summary button is wrapped in an `
` heading (the `heading` slot, overridable), and the panel takes `role="region"` named by `aria-labelledby` from the summary's `id`, so the header-to-panel relationship is programmatic.
+- axe-core `aria-valid-attr-value`, `duplicate-id-aria`, and `aria-allowed-attr` pass across the demos; whether `
` is the right level in the page outline is the author's responsibility.
+
+**Manual testing steps**
+
+1. In a page with an expanded accordion, inspect the accessibility tree.
+2. Confirm the header is a heading and the expanded panel is a region named by that header.
+
+**Pass:** the header is exposed as a heading and the panel as a region named by it.
+
+#### 1.4.3 Contrast (Minimum) · AA
+
+`🚩` · `✅ Supports` · `◐ Shared`
+
+- The summary label (`text.primary` or `text.secondary`) and the default panel content (`Typography`) sit on `paper` above 4.5:1; custom content colors and action-button themes are the author's.
+- axe-core `color-contrast` passes where it can resolve the background and returns incomplete on some demos, where axe cannot read the summary background behind the divider `::before` pseudo-element; no demo records a failure.
+
+**Manual testing steps**
+
+1. With a contrast checker, measure the summary label and the expanded panel text against their backgrounds.
+2. Check any custom theme colors the product uses.
+
+**Pass:** at least 4.5:1 for the summary label and panel text. Disabled summaries are exempt.
+
+#### 2.4.6 Headings and Labels · AA
+
+`✅ Supports` · `◐ Shared`
+
+- Each accordion provides an `
` heading whose text is the summary label; axe-core `button-name` confirms that label is present.
+- Whether the heading describes its section is an authoring concern.
+
+**Manual testing steps**
+
+1. Read each accordion heading out of context and ask whether it names its section.
+
+**Pass:** every heading describes its section.
+
+#### 4.1.2 Name, Role, Value · A
+
+`✅ Supports` · `◐ Shared`
+
+- The summary's `aria-expanded` (set by the component) exposes the required open or closed state; the panel takes `role="region"` named by `aria-labelledby`, and `aria-controls` links the header to it.
+- The region name depends on the author setting `id` and `aria-controls` on the summary, as every demo does; omitting them leaves the region unnamed. axe-core `aria-valid-attr-value` and `duplicate-id-aria` pass.
+
+**Manual testing steps**
+
+1. With a screen reader running, move to an accordion header and expand it.
+2. Confirm the collapsed or expanded state is announced, and the expanded panel is reached as a named region.
+
+**Pass:** state, role, and the header-to-region name are all exposed.
+
+### ⚙️ Automated
+
+#### 1.4.4 Resize Text · AA
+
+`✅ Supports` · `◐ Shared`
+
+- Header and panel text use rem and theme spacing, so the accordion scales with browser zoom; a fixed-pixel wrapper in the surrounding layout could clip at 200%. Covered by a Playwright test at 200% text size.
+
+#### 1.4.10 Reflow · AA
+
+`✅ Supports` · `◐ Shared`
+
+- The accordion is full width and its text wraps, so it reflows on its own; horizontal overflow at 320 CSS pixels comes from the surrounding layout. Covered by a Playwright test at a 320px viewport.
+
+#### 1.4.12 Text Spacing · AA
+
+`✅ Supports` · `◐ Shared`
+
+- Header and panel heights come from padding, not fixed heights, so the WCAG text-spacing values grow the accordion without clipping.
+- axe-core `avoid-inline-spacing` passes, and a visual-regression screenshot guards the layout. Covered by a Playwright test applying the WCAG text-spacing overrides.
+
+#### 2.1.1 Keyboard · A
+
+`✅ Supports` · `● Component`
+
+- The accordion expands and collapses through the summary's native activation (Enter and Space); the WAI-ARIA accordion pattern requires only that plus Tab, and does not define arrow-key navigation between headers.
+- Confirmed by interaction tests in [`../ButtonBase/ButtonBase.test.js`](../ButtonBase/ButtonBase.test.js) and an `onChange`-on-click test in [`Accordion.test.js`](./Accordion.test.js).
+
+#### 2.1.2 No Keyboard Trap · A
+
+`✅ Supports` · `● Component`
+
+- Each header is a single focusable stop and the collapsed panel is removed from the tab order, so Tab moves through the cluster and out without a trap.
+- Confirmed by a unit test in [`../AccordionSummary/AccordionSummary.test.js`](../AccordionSummary/AccordionSummary.test.js) (Tab enters and leaves the header freely).
+
+#### 2.4.3 Focus Order · A
+
+`✅ Supports` · `◐ Shared`
+
+- The collapsed panel is set to `visibility: hidden` by `Collapse`, so its content and any AccordionActions buttons leave the tab order; an expanded panel sits in DOM order right after its header.
+- Confirmed by unit tests in [`./Accordion.test.js`](./Accordion.test.js) (a collapsed panel gets the `Collapse` hidden state) and [`../AccordionSummary/AccordionSummary.test.js`](../AccordionSummary/AccordionSummary.test.js) (the header is a single tab stop; a disabled header leaves the order).
+
+#### 2.5.2 Pointer Cancellation · A
+
+`✅ Supports` · `● Component`
+
+- The accordion toggles on `click`, fired on pointer-up; `mousedown` alone does not toggle.
+- Confirmed by a unit test in [`../AccordionSummary/AccordionSummary.test.js`](../AccordionSummary/AccordionSummary.test.js) (releasing off the target does not toggle; a full click does). Covered by unit tests.
+
+#### 3.2.1 On Focus · A
+
+`✅ Supports` · `● Component`
+
+- Focusing a header applies the focus-visible tint only; it triggers no expand, navigation, or focus move, so focus alone changes no context.
+- Confirmed by a unit test in [`../AccordionSummary/AccordionSummary.test.js`](../AccordionSummary/AccordionSummary.test.js) (focusing the header does not toggle it).
+
+#### 3.2.2 On Input · A
+
+`✅ Supports` · `◐ Shared`
+
+- Expanding or collapsing a panel changes content within the same page, which is not a change of context; coupling activation to navigation would be an author decision.
+- Confirmed by a unit test in [`../AccordionSummary/AccordionSummary.test.js`](../AccordionSummary/AccordionSummary.test.js) (the panel toggles only on explicit activation, never on its own).
+
+## Not applicable
+
+- **1.3.5 Identify Input Purpose (AA).** Collects no user data.
+- **1.4.13 Content on Hover or Focus (AA).** The panel opens on activation, not on hover or focus.
+- **2.1.4 Character Key Shortcuts (A).** Implements no shortcut bound to a letter, punctuation, number, or symbol key; Enter and Space are standard activation, not character shortcuts.
+- **2.4.4 Link Purpose (In Context) (A).** The header is a button, not a link.
+- **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).** Provides no help mechanism.
+- **3.3.7 Redundant Entry (A, new in 2.2).** Re-enters no data.
+- **3.3.8 Accessible Authentication (Minimum) (AA, new in 2.2).** No authentication step.
+- **4.1.3 Status Messages (AA).** Adds no status region; the state is carried by `aria-expanded` on the focused header.
+- **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), Pause, Stop, Hide (2.2.2).** Sets no time limit; the expand transition is user-triggered.
+- **Three Flashes or Below Threshold (2.3.1).** Nothing flashes.
+- **Pointer Gestures (2.5.1), Motion Actuation (2.5.4).** Activates on a simple click; reads no device motion.
+- **Error Identification (3.3.1), Labels or Instructions (3.3.2), Error Suggestion (3.3.3), Error Prevention (3.3.4).** The accordion collects and validates no input; these belong to the form or process.
+
+## Inherited from AccordionSummary
+
+These item-level criteria are rated on the header button. See [AccordionSummary](../AccordionSummary/accessibility.md).
+
+- [1.1.1 Non-text Content](../AccordionSummary/accessibility.md)
+- [1.4.11 Non-text Contrast](../AccordionSummary/accessibility.md)
+- [2.4.7 Focus Visible](../AccordionSummary/accessibility.md)
+- [2.5.3 Label in Name](../AccordionSummary/accessibility.md)
+- [2.5.8 Target Size (Minimum)](../AccordionSummary/accessibility.md)
+
+## Level AAA
+
+- **2.3.3 Animation from Interactions.** The `Collapse` transition and chevron rotation honor `prefers-reduced-motion` only when the theme sets `motion.reducedMotion` to `system` or `always`; the default is `never`. `🚩`
+- **2.4.13 Focus Appearance.** The header focus tint is unlikely to meet the area and contrast thresholds. `🚩`
+- **2.5.5 Target Size (Enhanced), 44px.** The header (48px tall) meets it; nested action buttons may not. `🚩`
+- **1.4.6 Contrast (Enhanced), 7:1.** `text.primary` clears 7:1, but `text.secondary` (about 5.7:1) does not. `🚩`
+- 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.12 Focus Not Obscured (Enhanced)**.
+
+## Scope and test environment
+
+- **Standard.** WCAG 2.2, Level A and AA.
+- **Component version.** `@mui/material` 9.1.2.
+- **Scope.** The Accordion cluster rendered through its documented API: the root ``, plus the passive `AccordionDetails` (the panel's padding container) and `AccordionActions` (the optional action bar), both `
`s with no role or ARIA of their own. The interactive header `AccordionSummary` has its own [report](../AccordionSummary/accessibility.md) and is inherited here; author-supplied panel content and action buttons are the author's responsibility.
+- **Automated.** axe-core via Playwright test harness (results in [`accordion.a11y.json`](../../../../docs/data/material/components/accordion/accordion.a11y.json)), plus interaction tests in `Accordion.test.js`, `AccordionSummary.test.js`, and `ButtonBase.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/AccordionSummary/AccordionSummary.test.js b/packages/mui-material/src/AccordionSummary/AccordionSummary.test.js
index f602d8193dea07..4bd9bdafb51bf1 100644
--- a/packages/mui-material/src/AccordionSummary/AccordionSummary.test.js
+++ b/packages/mui-material/src/AccordionSummary/AccordionSummary.test.js
@@ -7,6 +7,7 @@ import AccordionSummary, {
} from '@mui/material/AccordionSummary';
import Accordion from '@mui/material/Accordion';
import ButtonBase from '@mui/material/ButtonBase';
+import SvgIcon from '@mui/material/SvgIcon';
import describeConformance from '../../test/describeConformance';
const CustomButtonBase = React.forwardRef(({ focusVisible, ...props }, ref) => (
@@ -114,22 +115,6 @@ describe('', () => {
expect(handleChange.callCount).to.equal(1);
});
- // JSDOM doesn't support :focus-visible
- it.skipIf(isJsdom())('calls onFocusVisible if focused visibly', function test() {
- const handleFocusVisible = spy();
- render();
- // simulate pointer device
- fireEvent.mouseDown(document.body);
-
- // this doesn't actually apply focus like in the browser. we need to move focus manually
- fireEvent.keyDown(document.body, { key: 'Tab' });
- act(() => {
- screen.getByRole('button').focus();
- });
-
- expect(handleFocusVisible.callCount).to.equal(1);
- });
-
describe('prop: nativeButton', () => {
it('forwards nativeButton={false} through useSlot to ButtonBase', () => {
const CustomSpan = React.forwardRef((props, ref) => );
@@ -152,4 +137,189 @@ describe('', () => {
errorSpy.mockRestore();
});
});
+
+ describe('WCAG 2.2 conformance', () => {
+ it('2.1.2 No Keyboard Trap: keyboard focus can enter and leave the summary', async () => {
+ const { user } = render(
+
+
+
+ Summary
+
+
+ ,
+ );
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'Summary' })).toHaveFocus();
+
+ // Tab moves focus back out of the summary — 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: 'Summary' })).toHaveFocus();
+ });
+
+ describe('2.4.3 Focus Order', () => {
+ it('is a single tab stop in natural DOM order with no positive tabIndex', async () => {
+ const { user } = render(
+
+
+
+ Summary
+
+
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Summary' })).to.have.property('tabIndex', 0);
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'Summary' })).toHaveFocus();
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
+ });
+
+ it('removes a disabled summary from the tab order', async () => {
+ const { user } = render(
+
+
+ Disabled
+
+
+ ,
+ );
+
+ // Tab skips the disabled summary and lands on the next control.
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
+ });
+ });
+
+ // JSDOM doesn't support :focus-visible
+ it.skipIf(isJsdom())(
+ '2.4.7 Focus Visible: applies the focus-visible state on keyboard focus',
+ function test() {
+ const handleFocusVisible = spy();
+ render();
+ // simulate pointer device
+ fireEvent.mouseDown(document.body);
+
+ // this doesn't actually apply focus like in the browser. we need to move focus manually
+ fireEvent.keyDown(document.body, { key: 'Tab' });
+ act(() => {
+ screen.getByRole('button').focus();
+ });
+
+ expect(handleFocusVisible.callCount).to.equal(1);
+ },
+ );
+
+ it('2.5.2 Pointer Cancellation: activates on click, but not when released off the target', async () => {
+ const handleChange = spy();
+ const { user } = render(
+
+
+ Summary
+
+
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ // Press on the summary, 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(handleChange.callCount).to.equal(0);
+
+ // A full click — press and release over the target — toggles the accordion.
+ await user.click(button);
+ expect(handleChange.callCount).to.equal(1);
+ });
+
+ it('3.2.1 On Focus: moving keyboard focus to the summary does not toggle it', async () => {
+ const handleChange = spy();
+ const { user } = render(
+
+ Summary
+ ,
+ );
+
+ await user.tab();
+ expect(screen.getByRole('button')).toHaveFocus();
+ // Focus alone changes no context.
+ expect(handleChange.callCount).to.equal(0);
+ });
+
+ it('3.2.2 On Input: the panel toggles only from explicit activation, never on its own', async () => {
+ const handleChange = spy();
+ const { user } = render(
+
+ Summary
+ ,
+ );
+
+ // Rendering the summary and its aria-expanded state toggles nothing on its own.
+ expect(handleChange.callCount).to.equal(0);
+
+ // The panel toggles only when the user explicitly activates the summary.
+ await user.click(screen.getByRole('button'));
+ expect(handleChange.callCount).to.equal(1);
+ });
+
+ it('2.5.3 Label in Name: the accessible name is the visible label', () => {
+ render(
+
+
+
+
+ }
+ >
+ Billing details
+
+ ,
+ );
+
+ // An SvgIcon expandIcon is aria-hidden by default, so the name is exactly
+ // the visible text. getByRole with `name` only resolves on an exact match.
+ expect(screen.getByRole('button', { name: 'Billing details' })).not.to.equal(null);
+ });
+
+ describe('4.1.2 Name, Role, Value', () => {
+ it('exposes the button role with its accessible name', () => {
+ render(
+
+ Shipping
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Shipping' })).to.have.tagName('button');
+ });
+
+ it('reflects the open state through aria-expanded', async () => {
+ const { user } = render(
+
+ Shipping
+ ,
+ );
+ const summary = screen.getByRole('button');
+ expect(summary).to.have.attribute('aria-expanded', 'false');
+
+ await user.click(summary);
+ expect(summary).to.have.attribute('aria-expanded', 'true');
+ });
+ });
+ });
});
diff --git a/packages/mui-material/src/AccordionSummary/accessibility.md b/packages/mui-material/src/AccordionSummary/accessibility.md
new file mode 100644
index 00000000000000..df4cf6c6628e95
--- /dev/null
+++ b/packages/mui-material/src/AccordionSummary/accessibility.md
@@ -0,0 +1,318 @@
+# AccordionSummary accessibility conformance
+
+Rated against WCAG 2.2 Level A and AA. See the [reports legend](../accessibility.md).
+
+This is the item-level report for the accordion header button. The root `` inherits these criteria; see [Accordion](../Accordion/accessibility.md).
+
+| Result | Count |
+| :-------------------- | :---- |
+| ✅ Supports | 23 |
+| ⚠️ Partially Supports | 1 |
+| ❌ Does Not Support | 0 |
+| ➖ Not Applicable | 31 |
+| 🚩 Flagged | 4/24 |
+
+## Known gaps
+
+- ⚠️ **1.4.11 Non-text Contrast.** The keyboard-focus indicator is only a background tint (`action.focus`), about 1.3:1 against the adjacent `paper` surface, below the 3:1 minimum.
+
+## Success criteria
+
+### 🔍 Manual
+
+#### 1.3.2 Meaningful Sequence · A
+
+`✅ Supports` · `○ Author`
+
+- The summary is one control whose slots render in DOM order: the content `span`, then the `expandIcon` wrapper; the decorative icon is `aria-hidden`, leaving the label as the exposed content.
+- Order carries meaning only across the surrounding controls, which the page layout sets.
+
+**Manual testing steps**
+
+1. In an accordion, read the summary content from left to right.
+2. Compare it to the order announced by a screen reader.
+
+**Pass:** the announced order matches the visual order.
+
+#### 1.3.3 Sensory Characteristics · A
+
+`✅ Supports` · `○ Author`
+
+- The summary is identified by its text label, not only by the position or shape of the `expandIcon`.
+- Surrounding instructions must not rely on the icon's direction alone (for example, "open the section with the down arrow").
+
+**Manual testing steps**
+
+1. Find any product copy that tells users to expand a section.
+2. Check that it names the section by its heading, not only by the chevron's shape or position.
+
+**Pass:** no instruction depends on the icon alone.
+
+#### 1.4.1 Use of Color · A
+
+`🚩` · `✅ Supports` · `◐ Shared`
+
+- The expanded state is conveyed by `aria-expanded` and the rotated `expandIcon`, not by color, and the label carries the meaning.
+
+**Manual testing steps**
+
+1. In a UI with accordions, turn on a grayscale view (Chrome DevTools: Rendering, Emulate vision deficiencies, Achromatopsia).
+2. Expand and collapse a panel.
+
+**Pass:** the open and closed states stay distinguishable without color.
+
+#### 1.4.5 Images of Text · AA
+
+`✅ Supports` · `○ Author`
+
+- The label is real text passed as children.
+
+**Manual testing steps**
+
+1. Verify that the summary label is real text.
+
+**Pass:** no summary renders its label as an image of text.
+
+#### 1.4.11 Non-text Contrast · AA
+
+`🚩` · `⚠️ Partially Supports` · `● Component`
+
+- The keyboard-focus indicator is a background change to `action.focus` (`rgba(0, 0, 0, 0.12)`), about 1.3:1 against the adjacent `paper` surface, below the 3:1 minimum; `ButtonBase` sets `outline: 0`, so no outline backs it up.
+- The `expandIcon` uses `action.active` (`rgba(0, 0, 0, 0.54)`), about 4.6:1 on `paper`, which clears 3:1.
+
+**Manual testing steps**
+
+1. Press Tab to focus an accordion summary.
+2. With a contrast checker, measure the focused background against the resting background, and the chevron against the surface behind it.
+
+**Pass:** the focus indicator and the icon are each at least 3:1. The focus indicator is the known shortfall.
+
+#### 2.4.11 Focus Not Obscured (Minimum) · AA
+
+`✅ Supports` · `○ Author`
+
+- The summary never hides itself; obscuring typically comes from sticky headers or overlays in the surrounding layout.
+
+**Manual testing steps**
+
+1. In a page with a sticky header, press Tab to a summary near it.
+2. Scroll so the summary sits under the sticky element, then Tab to it again.
+
+**Pass:** at least part of the focused summary stays visible, never entirely covered.
+
+#### 3.2.4 Consistent Identification · AA
+
+`✅ Supports` · `○ Author`
+
+- The component produces one stable accessible name per set of props.
+
+**Manual testing steps**
+
+1. Compare accordions with same purpose across the product.
+
+**Pass:** consistently uses the same text and icon.
+
+### 🔁 Hybrid
+
+#### 1.1.1 Non-text Content · A
+
+`✅ Supports` · `◐ Shared`
+
+- The `expandIcon` (an `SvgIcon`) defaults to `aria-hidden` and `focusable="false"`, so it is decorative and the name comes from the summary children.
+- axe-core `button-name` and `aria-command-name` confirm a name is present across the demos; whether the name conveys the section's purpose needs an assistive-technology review.
+
+**Manual testing steps**
+
+1. With a screen reader running (NVDA with Chrome, or VoiceOver with Safari), Tab to accordion summaries that carry an `expandIcon` and confirm each announces the label, not the chevron.
+
+**Pass:** every summary's announced name matches its section, and the chevron is silent.
+
+#### 1.3.1 Info and Relationships · A
+
+`✅ Supports` · `◐ Shared`
+
+- The summary exposes its `button` role and its `aria-expanded` state programmatically; the disclosure relationship to the panel is wired by the root `` (see [Accordion](../Accordion/accessibility.md)).
+- axe-core `aria-allowed-attr`, `aria-valid-attr`, and `aria-valid-attr-value` pass across the demos.
+
+**Manual testing steps**
+
+1. With a screen reader running, move to an accordion summary.
+2. Confirm it announces a collapsed or expanded button.
+
+**Pass:** role and state match the visual presentation.
+
+#### 1.4.3 Contrast (Minimum) · AA
+
+`🚩` · `✅ Supports` · `● Component`
+
+- The label uses `text.primary` (about 16:1) or `text.secondary` (about 5.7:1) on `paper`, both above 4.5:1; disabled summaries drop to `0.38` opacity and are exempt.
+- axe-core `color-contrast` passes where it can resolve the background and returns incomplete on some demos, where axe cannot read the summary background behind the divider `::before` pseudo-element; no demo records a failure, so a visual check is the remaining step.
+
+**Manual testing steps**
+
+1. With a contrast checker, measure each summary label, including any secondary `text.secondary` line, against its background.
+2. Check any custom theme colors the product uses.
+
+**Pass:** at least 4.5:1 for the label text. Disabled summaries are exempt.
+
+#### 2.4.6 Headings and Labels · AA
+
+`✅ Supports` · `◐ Shared`
+
+- The accessible name serves as the summary's label; axe-core `button-name` confirms it is present.
+- Whether the label describes the section is an authoring concern.
+
+**Manual testing steps**
+
+1. Read each summary label out of context.
+2. Ask whether it says what the section contains.
+
+**Pass:** every label describes its section, not a vague "Details".
+
+#### 2.4.7 Focus Visible · AA
+
+`🚩` · `✅ Supports` · `● Component`
+
+- Keyboard focus applies the `.Mui-focusVisible` background (`action.focus`); mouse focus is suppressed, and `ButtonBase` removes the native outline, so this tint is the only indicator.
+- Covered by a unit test in [`AccordionSummary.test.js`](./AccordionSummary.test.js) that confirms the focus-visible state fires; whether the tint is perceptible enough is the visual step (its contrast is the 1.4.11 shortfall).
+
+**Manual testing steps**
+
+1. Press Tab to move focus across accordion summaries.
+2. Confirm a focus indicator appears and differs from the resting style.
+3. Click a summary with the mouse and confirm the indicator does not appear.
+
+**Pass:** every keyboard-focused summary shows a visible indicator.
+
+#### 4.1.2 Name, Role, Value · A
+
+`✅ Supports` · `◐ Shared`
+
+- The native summary is a `button`; with `nativeButton={false}` and a custom `component`, `ButtonBase` applies `role="button"`. The name comes from the children and the state from `aria-expanded`.
+- axe-core `button-name`, `aria-command-name`, `aria-allowed-attr`, `aria-roles`, `nested-interactive`, and `duplicate-id-aria` pass; a unit test in [`AccordionSummary.test.js`](./AccordionSummary.test.js) confirms `aria-expanded` tracks the state. Whether the name is meaningful needs an assistive-technology review.
+
+**Manual testing steps**
+
+1. With a screen reader running, Tab to a native summary and a non-native (`nativeButton={false}`) summary.
+2. Confirm each announces the name, the button role, and the collapsed or expanded state.
+
+**Pass:** name, role, and state are correct for the native and non-native summaries.
+
+### ⚙️ Automated
+
+#### 1.4.4 Resize Text · AA
+
+`✅ Supports` · `◐ Shared`
+
+- The label text is rem-based (`Typography`), and the 48px `min-height` is a floor that content grows past, not a fixed size, so the summary scales with browser zoom rather than clipping.
+- 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`
+
+- The summary is full width and its label wraps, so it reflows on its own; horizontal overflow at 320 CSS pixels comes from the surrounding layout. Covered by a Playwright test at a 320px viewport.
+
+#### 1.4.12 Text Spacing · AA
+
+`✅ Supports` · `◐ Shared`
+
+- The label wraps and the summary height comes from `min-height` and padding, not a fixed height, so the WCAG text-spacing values grow the control without clipping.
+- axe-core `avoid-inline-spacing` passes, and a visual-regression screenshot guards the layout. Covered by a Playwright test applying the WCAG text-spacing overrides.
+
+#### 2.1.1 Keyboard · A
+
+`✅ Supports` · `● Component`
+
+- The native summary activates with Enter and Space; the non-native `role="button"` gets the same handling from `ButtonBase`, and a disabled summary leaves the tab order.
+- Confirmed by interaction tests in [`../ButtonBase/ButtonBase.test.js`](../ButtonBase/ButtonBase.test.js).
+
+#### 2.1.2 No Keyboard Trap · A
+
+`✅ Supports` · `● Component`
+
+- The summary is a single focusable control that installs no focus-capturing loop; Tab moves in and out, and a disabled summary leaves the order.
+- Confirmed by a unit test in [`./AccordionSummary.test.js`](./AccordionSummary.test.js) (Tab is not intercepted, and focus moves away freely).
+
+#### 2.4.3 Focus Order · A
+
+`✅ Supports` · `◐ Shared`
+
+- The summary sits in natural DOM order with no positive `tabIndex`, so it is one correct focus stop; order across the cluster is covered in [Accordion](../Accordion/accessibility.md).
+- Confirmed by a unit test in [`./AccordionSummary.test.js`](./AccordionSummary.test.js) (default `tabIndex` is `0`; a disabled summary leaves the order).
+
+#### 2.5.2 Pointer Cancellation · A
+
+`✅ Supports` · `● Component`
+
+- Activation runs on `click`, fired on pointer-up over the target; `mousedown` alone does not toggle, so releasing off the target cancels.
+- Confirmed by a unit test in [`./AccordionSummary.test.js`](./AccordionSummary.test.js) (releasing off the target does not toggle; a full click does). Covered by unit tests.
+
+#### 2.5.3 Label in Name · A
+
+`✅ Supports` · `◐ Shared`
+
+- The accessible name is the visible label: the children become the name, and an `SvgIcon` `expandIcon` defaults to `aria-hidden`; a custom icon node adds to the name unless the author hides it.
+- An `aria-label` that omits or reorders the visible words breaks this; compare the visible text to the computed name. Covered by unit tests.
+
+#### 2.5.8 Target Size (Minimum) · AA
+
+`✅ Supports` · `● Component`
+
+- The summary is full width with a 48px `min-height` (64px when expanded), well above the 24 by 24 CSS pixel minimum. axe-core `target-size` confirms this across the demos in [`accordion.a11y.json`](../../../../docs/data/material/components/accordion/accordion.a11y.json).
+- Not covered: `sx` overrides that shrink a custom summary.
+
+#### 3.2.1 On Focus · A
+
+`✅ Supports` · `● Component`
+
+- Focusing the summary applies the focus-visible tint and runs `onFocus` callbacks only; there is no navigation or focus move, so focus alone changes no context.
+- Confirmed by a unit test in [`./AccordionSummary.test.js`](./AccordionSummary.test.js) (focusing the summary does not toggle it).
+
+#### 3.2.2 On Input · A
+
+`✅ Supports` · `◐ Shared`
+
+- Activating the summary toggles the panel within the same page, which is not a change of context.
+- Whether an author's `onClick` couples activation to navigation is an author decision.
+- Confirmed by a unit test in [`./AccordionSummary.test.js`](./AccordionSummary.test.js) (the panel toggles only on explicit activation, never on its own).
+
+## Not applicable
+
+- **1.3.5 Identify Input Purpose (AA).** Collects no user data.
+- **1.4.13 Content on Hover or Focus (AA).** The panel opens on activation, not on hover or focus.
+- **2.1.4 Character Key Shortcuts (A).** Implements no shortcut bound to a letter, punctuation, number, or symbol key; Enter and Space are standard activation, not character shortcuts.
+- **2.4.4 Link Purpose (In Context) (A).** The summary is a button, not a link.
+- **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).** Provides no help mechanism.
+- **3.3.7 Redundant Entry (A, new in 2.2).** Re-enters no data.
+- **3.3.8 Accessible Authentication (Minimum) (AA, new in 2.2).** No authentication step.
+- **4.1.3 Status Messages (AA).** Adds no status region; the state is carried by `aria-expanded` on the focused summary.
+- **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), Pause, Stop, Hide (2.2.2).** Sets no time limit and nothing moves on its own.
+- **Three Flashes or Below Threshold (2.3.1).** Nothing flashes.
+- **Pointer Gestures (2.5.1), Motion Actuation (2.5.4).** Activates on a simple click; reads no device motion.
+- **Error Identification (3.3.1), Labels or Instructions (3.3.2), Error Suggestion (3.3.3), Error Prevention (3.3.4).** The summary collects and validates no input; these belong to the form or process.
+
+## Level AAA
+
+The following SC are applicable but out of scope:
+
+- **2.3.3 Animation from Interactions.** The `expandIcon` rotation honors `prefers-reduced-motion` only when the theme sets `motion.reducedMotion` to `system` or `always`; the default is `never`. `🚩`
+- **2.4.13 Focus Appearance.** The focus tint is unlikely to meet the area and contrast thresholds. `🚩`
+- **2.5.5 Target Size (Enhanced), 44px.** The summary (48px tall) meets it; nested action buttons may not. `🚩`
+- **1.4.6 Contrast (Enhanced), 7:1.** `text.primary` clears 7:1, but `text.secondary` (about 5.7:1) does not. `🚩`
+- 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.12 Focus Not Obscured (Enhanced)**.
+
+## Scope and test environment
+
+- **Standard.** WCAG 2.2, Level A and AA.
+- **Component version.** `@mui/material` 9.1.2.
+- **Scope.** The AccordionSummary header button, rendered through its documented API inside an Accordion.
+- **Automated.** axe-core via Playwright test harness (results in [`accordion.a11y.json`](../../../../docs/data/material/components/accordion/accordion.a11y.json)), plus interaction tests in `AccordionSummary.test.js` and `ButtonBase.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/Tabs/Tabs.js b/packages/mui-material/src/Tabs/Tabs.js
index ebc6a5663a6d21..056faba644d51a 100644
--- a/packages/mui-material/src/Tabs/Tabs.js
+++ b/packages/mui-material/src/Tabs/Tabs.js
@@ -713,6 +713,23 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
scrollSelectedIntoView(defaultIndicatorStyle !== indicatorStyle);
}, [scrollSelectedIntoView, indicatorStyle]);
+ React.useEffect(() => {
+ if (typeof ResizeObserver === 'undefined' || !scrollable || scrollButtons !== 'auto') {
+ return undefined;
+ }
+
+ // Mounting the scroll buttons shrinks the scroller after `scrollSelectedIntoView` has run,
+ // which can push the selected tab out of view without changing `indicatorStyle`.
+ const scrollerResizeObserver = new ResizeObserver(() => {
+ scrollSelectedIntoView(false);
+ });
+ scrollerResizeObserver.observe(tabsRef.current);
+
+ return () => {
+ scrollerResizeObserver.disconnect();
+ };
+ }, [scrollable, scrollButtons, scrollSelectedIntoView]);
+
React.useImperativeHandle(
action,
() => ({
diff --git a/packages/mui-material/src/Tabs/Tabs.test.js b/packages/mui-material/src/Tabs/Tabs.test.js
index e7ba3244755fbc..25f851cd2bdbeb 100644
--- a/packages/mui-material/src/Tabs/Tabs.test.js
+++ b/packages/mui-material/src/Tabs/Tabs.test.js
@@ -48,6 +48,44 @@ function hasRightScrollButton(container) {
return !scrollButton.parentElement.classList.contains('Mui-disabled');
}
+// jsdom has no ResizeObserver and no layout, so observed elements are mapped to their callback
+// to let tests fire resizes by hand.
+function mockResizeObserver() {
+ const callbacks = new Map();
+ const original = globalThis.ResizeObserver;
+
+ globalThis.ResizeObserver = class {
+ constructor(callback) {
+ this.callback = callback;
+ this.elements = new Set();
+ }
+
+ observe(element) {
+ this.elements.add(element);
+ callbacks.set(element, this.callback);
+ }
+
+ unobserve(element) {
+ this.elements.delete(element);
+ callbacks.delete(element);
+ }
+
+ disconnect() {
+ this.elements.forEach((element) => {
+ callbacks.delete(element);
+ });
+ this.elements.clear();
+ }
+ };
+
+ return {
+ callbacks,
+ restore() {
+ globalThis.ResizeObserver = original;
+ },
+ };
+}
+
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const isFirefox = /firefox/i.test(navigator.userAgent);
@@ -950,6 +988,87 @@ describe.skipIf(isSafari)('', () => {
clock.tick(1000);
expect(tablistContainer.scrollLeft).to.equal(0);
});
+
+ // Firefox reports fractional `scrollLeft` in Vitest browser mode.
+ // See https://github.com/vitest-dev/vitest/issues/9223
+ it.skipIf(isFirefox)(
+ 'should scroll the selected tab into view when the scroller resizes (scrollButtons="auto")',
+ () => {
+ const { callbacks, restore } = mockResizeObserver();
+
+ try {
+ render(
+
+
+
+
+ ,
+ );
+
+ const tablist = screen.getByRole('tablist');
+ const tablistContainer = tablist.parentElement;
+ const selectedTab = tablist.children[2];
+
+ // Mounting the scroll buttons narrows the scroller, leaving the selected tab
+ // overhanging its right edge by 110px.
+ tablistContainer.getBoundingClientRect = () => ({ left: 40, right: 160 });
+ selectedTab.getBoundingClientRect = () => ({ left: 150, right: 270 });
+ tablistContainer.scrollLeft = 0;
+
+ const scrollerCallback = callbacks.get(tablistContainer);
+ expect(scrollerCallback).not.to.equal(undefined);
+
+ scrollerCallback([]);
+
+ expect(tablistContainer.scrollLeft).to.equal(110);
+ } finally {
+ restore();
+ }
+ },
+ );
+
+ it('should not observe the scroller when scrollButtons is not "auto"', () => {
+ const { callbacks, restore } = mockResizeObserver();
+
+ try {
+ render(
+
+
+
+
+ ,
+ );
+
+ const tablistContainer = screen.getByRole('tablist').parentElement;
+
+ expect(callbacks.has(tablistContainer)).to.equal(false);
+ } finally {
+ restore();
+ }
+ });
+
+ it('should stop observing the scroller on unmount', () => {
+ const { callbacks, restore } = mockResizeObserver();
+
+ try {
+ const { unmount } = render(
+
+
+
+
+ ,
+ );
+
+ const tablistContainer = screen.getByRole('tablist').parentElement;
+ expect(callbacks.has(tablistContainer)).to.equal(true);
+
+ unmount();
+
+ expect(callbacks.has(tablistContainer)).to.equal(false);
+ } finally {
+ restore();
+ }
+ });
});
describe('slotProps: indicator', () => {
diff --git a/packages/mui-material/src/accessibility.md b/packages/mui-material/src/accessibility.md
index 99165cd6195c74..69d73ed6a36142 100644
--- a/packages/mui-material/src/accessibility.md
+++ b/packages/mui-material/src/accessibility.md
@@ -65,6 +65,8 @@ Components are rated in isolation against WCAG 2.2 A and AA. The levels are [cum
| Component | ✅ Supports | ⚠️ Partially Supports | ❌ Does Not Support | ➖ Not Applicable |
| :-------------------------------------------------------- | :---------- | :-------------------- | :------------------ | :---------------- |
+| [Accordion](./Accordion/accessibility.md) | 19 | 0 | 0 | 31 |
+| [AccordionSummary](./AccordionSummary/accessibility.md) | 23 | 1 | 0 | 31 |
| [Avatar](./Avatar/accessibility.md) | 9 | 2 | 0 | 44 |
| [Button](./Button/accessibility.md) | 23 | 4 | 0 | 28 |
| [LinearProgress](./LinearProgress/accessibility.md) | 8 | 3 | 0 | 44 |
diff --git a/test/regressions/a11y/fixtures/accordion/AccordionA11yNonNative.js b/test/regressions/a11y/fixtures/accordion/AccordionA11yNonNative.js
new file mode 100644
index 00000000000000..89c4d21552ce6a
--- /dev/null
+++ b/test/regressions/a11y/fixtures/accordion/AccordionA11yNonNative.js
@@ -0,0 +1,48 @@
+import * as React from 'react';
+import Accordion from '@mui/material/Accordion';
+import AccordionSummary from '@mui/material/AccordionSummary';
+import AccordionDetails from '@mui/material/AccordionDetails';
+import Typography from '@mui/material/Typography';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+
+const CustomDivSummary = React.forwardRef(function CustomDivSummary(props, ref) {
+ return ;
+});
+
+export default function AccordionA11yNonNative() {
+ const id = React.useId();
+ return (
+
+
+ }
+ aria-controls={`${id}-panel1-content`}
+ id={`${id}-panel1-header`}
+ >
+ Non-native summary
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex,
+ sit amet blandit leo lobortis eget.
+
+
+
+ }
+ aria-controls={`${id}-panel2-content`}
+ id={`${id}-panel2-header`}
+ >
+ Disabled non-native summary
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex,
+ sit amet blandit leo lobortis eget.
+
+
+
+ );
+}
diff --git a/test/regressions/a11y/fixtures/accordion/AccordionA11yTextSpacing.js b/test/regressions/a11y/fixtures/accordion/AccordionA11yTextSpacing.js
new file mode 100644
index 00000000000000..24ad0373d715c4
--- /dev/null
+++ b/test/regressions/a11y/fixtures/accordion/AccordionA11yTextSpacing.js
@@ -0,0 +1,30 @@
+import * as React from 'react';
+import Accordion from '@mui/material/Accordion';
+import AccordionSummary from '@mui/material/AccordionSummary';
+import AccordionDetails from '@mui/material/AccordionDetails';
+import Typography from '@mui/material/Typography';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+
+export default function AccordionA11yTextSpacing() {
+ const id = React.useId();
+ return (
+
+ }
+ aria-controls={`${id}-panel1-content`}
+ id={`${id}-panel1-header`}
+ >
+
+ Review your accessibility settings before continuing to the next step
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex,
+ sit amet blandit leo lobortis eget. Vivamus auctor neque a sapien fringilla, in dictum
+ massa pretium.
+
+
+
+ );
+}
diff --git a/test/regressions/demoMeta.test.ts b/test/regressions/demoMeta.test.ts
index d177386586eb59..0463fc4e751123 100644
--- a/test/regressions/demoMeta.test.ts
+++ b/test/regressions/demoMeta.test.ts
@@ -205,6 +205,35 @@ describe('getConfig', () => {
});
});
+describe('getConfig (accordion a11y)', () => {
+ it('enrols the accordion demos and fixtures for all-rule assertions', () => {
+ expect(
+ getConfig(A11Y_RULES, 'docs/data/material/components/accordion/AccordionUsage'),
+ ).to.deep.include({ enabled: true, assertions: 'all' });
+ expect(
+ getConfig(A11Y_RULES, 'test/regressions/a11y/fixtures/accordion/AccordionA11yNonNative'),
+ ).to.deep.include({ enabled: true, assertions: 'all' });
+ });
+
+ it('records but does not assert color-contrast on the accordion cluster', () => {
+ // The divider `::before` makes axe unable to resolve the summary background.
+ expect(
+ getConfig(A11Y_RULES, 'docs/data/material/components/accordion/CustomizedAccordions'),
+ ).to.deep.include({
+ enabled: true,
+ assertions: 'all',
+ skipAssertions: ['color-contrast'],
+ });
+ });
+
+ it('returns undefined for an accordion demo outside the brace-glob enrolment', () => {
+ // The accordion enrolment lists specific demos; it is not slug-wide.
+ expect(
+ getConfig(A11Y_RULES, 'docs/data/material/components/accordion/SomeUnenrolledDemo'),
+ ).to.equal(undefined);
+ });
+});
+
describe('minReactMajor', () => {
it('marks the crud-dashboard template as needing React 19', () => {
expect(
diff --git a/test/regressions/demoMeta.ts b/test/regressions/demoMeta.ts
index c3fe04ec16ed8e..b4fb1bb71b98f4 100644
--- a/test/regressions/demoMeta.ts
+++ b/test/regressions/demoMeta.ts
@@ -174,12 +174,25 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [
// 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
+ { test: 'test/regressions/a11y/fixtures/accordion/AccordionA11yTextSpacing', enabled: true }, // Visual regression for text spacing (1.4.12); adds no unique axe coverage
{
test: 'test/regressions/a11y/fixtures/toggle-button/ToggleButtonA11yTextSpacing',
enabled: true,
}, // Visual regression for text spacing (1.4.12); adds no unique axe coverage
];
+// Accordion docs demos + a11y fixtures enrolled for axe assertions (the cluster:
+// root Accordion + AccordionSummary header + AccordionDetails/Actions).
+const ACCORDION_A11Y_DEMOS = [
+ 'AccordionUsage',
+ 'AccordionExpandDefault',
+ 'AccordionExpandIcon',
+ 'ControlledAccordions',
+ 'CustomizedAccordions',
+ 'DisabledAccordion',
+ 'AccordionTransition',
+];
+
// toggle-button docs demos enrolled for axe assertions; the remaining demos add
// no axe coverage beyond the a11y fixtures.
const TOGGLE_BUTTON_A11Y_DEMOS = [
@@ -263,6 +276,25 @@ export const A11Y_RULES: A11yRule[] = [
enabled: true,
skipAssertions: ['color-contrast'],
},
+ {
+ // `color-contrast` is recorded but not asserted: the Accordion root's
+ // divider `::before` pseudo-element blocks axe's background resolution for
+ // the summary label, so the rule returns `incomplete` on some demos.
+ // No demo records a contrast failure; the label clears 4.5:1 on `paper`.
+ test: `docs/data/material/components/accordion/{${ACCORDION_A11Y_DEMOS.join(',')}}`,
+ enabled: true,
+ assertions: 'all',
+ skipAssertions: ['color-contrast'],
+ },
+ // A11y-only fixtures live under `test/regressions/a11y/fixtures/accordion/`
+ // (no docs page consumes them); the suite name maps their results into the
+ // same `accordion.a11y.json` as the docs demos above. The divider
+ // `::before` skip is not needed here: both fixtures pass `color-contrast`.
+ {
+ test: 'test/regressions/a11y/fixtures/accordion/{AccordionA11yNonNative,AccordionA11yTextSpacing}',
+ enabled: true,
+ assertions: 'all',
+ },
{
test: `docs/data/material/components/buttons/{${BUTTON_A11Y_DEMOS.join(',')}}`,
enabled: true,