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,