` element.
-```jsx
+```jsx title="Anatomy"
import Accordion from '@mui/material/Accordion';
+import AccordionActions from '@mui/material/AccordionActions';
import AccordionDetails from '@mui/material/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary';
+import Button from '@mui/material/Button';
+
+
;
```
+## Usage guidelines
+
+- **Make summaries descriptive**: The summary is the accordion header button, so it should clearly identify the content that expands.
+- **Avoid nested controls**: `AccordionSummary` renders a button, so don't put buttons, links, or other interactive elements inside it. Place those controls in `AccordionDetails` or `AccordionActions` instead.
+
+## Basics
+
### Expand icon
Use the `expandIcon` prop on the Accordion Summary component to change the expand indicator icon.
@@ -50,11 +69,22 @@ Use the `defaultExpanded` prop on the Accordion component to have it opened by d
{{"demo": "AccordionExpandDefault.js", "bg": true}}
-### Transition
+### Actions
-Use the `slots.transition` and `slotProps.transition` props to change the Accordion's default transition.
+Use the `AccordionActions` component to group buttons related to the panel content.
-{{"demo": "AccordionTransition.js", "bg": true}}
+```jsx
+
+```
### Disabled item
@@ -62,7 +92,23 @@ Use the `disabled` prop on the Accordion component to disable interaction and fo
{{"demo": "DisabledAccordion.js", "bg": true}}
-### Controlled Accordion
+## Customization
+
+### Heading level
+
+By default, the Accordion uses an `h3` element for the heading. You can change the heading element using the `slotProps.heading.component` prop to ensure the correct heading hierarchy in your document.
+
+```jsx
+
+```
+
+### Controlled accordion
The Accordion component can be controlled or uncontrolled.
@@ -76,8 +122,6 @@ The Accordion component can be controlled or uncontrolled.
Learn more about controlled and uncontrolled components in the [React documentation](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components).
:::
-## Customization
-
### Only one expanded at a time
Use the `expanded` prop with React's `useState` hook to allow only one Accordion item to be expanded at a time.
@@ -85,27 +129,13 @@ The demo below also shows a bit of visual customization.
{{"demo": "CustomizedAccordions.js", "bg": true}}
-### Changing heading level
+### Transition
-By default, the Accordion uses an `h3` element for the heading. You can change the heading element using the `slotProps.heading.component` prop to ensure the correct heading hierarchy in your document.
+Use the `slots.transition` and `slotProps.transition` props to change the Accordion's default transition.
-```jsx
-
-```
+{{"demo": "AccordionTransition.js", "bg": true}}
-## Performance
+### Unmounting collapsed content
The Accordion content is mounted by default even if it's not expanded.
This default behavior has server-side rendering and SEO in mind.
@@ -115,38 +145,3 @@ If you render the Accordion Details with a big component tree nested inside, or
```jsx
```
-
-## Accessibility
-
-The [WAI-ARIA guidelines for accordions](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) recommend setting an `id` and `aria-controls`, which in this case would apply to the Accordion Summary component.
-The Accordion component then derives the necessary `aria-labelledby` and `id` from its content.
-
-```jsx
-
` that contains the Accordion Summary, Accordion Details, and optional Accordion Actions for action buttons.
-
-```jsx
-
-```
diff --git a/packages/mui-material/src/Accordion/Accordion.js b/packages/mui-material/src/Accordion/Accordion.js
index e072881bde2ba3..92248ebee39024 100644
--- a/packages/mui-material/src/Accordion/Accordion.js
+++ b/packages/mui-material/src/Accordion/Accordion.js
@@ -12,9 +12,13 @@ import Collapse from '../Collapse';
import Paper from '../Paper';
import AccordionContext from './AccordionContext';
import useControlled from '../utils/useControlled';
+import useId from '../utils/useId';
import useSlot from '../utils/useSlot';
+import mergeSlotProps from '../utils/mergeSlotProps';
import accordionClasses, { getAccordionUtilityClass } from './accordionClasses';
+const EMPTY = {};
+
const useUtilityClasses = (ownerState) => {
const { classes, square, expanded, disabled, disableGutters } = ownerState;
@@ -148,8 +152,8 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) {
disableGutters = false,
expanded: expandedProp,
onChange,
- slots = {},
- slotProps = {},
+ slots = EMPTY,
+ slotProps = EMPTY,
...other
} = props;
@@ -171,11 +175,14 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) {
[expanded, onChange, setExpandedState],
);
- const [summary, ...children] = React.Children.toArray(childrenProp);
- const contextValue = React.useMemo(
- () => ({ expanded, disabled, disableGutters, toggle: handleChange }),
- [expanded, disabled, disableGutters, handleChange],
- );
+ const [summary, ...regionChildren] = React.Children.toArray(childrenProp);
+ const summaryProps = React.isValidElement(summary) ? summary.props : EMPTY;
+ const summaryIdProp = summaryProps.id;
+ const regionIdProp = summaryProps['aria-controls'];
+ const summaryId = useId(summaryIdProp);
+ const regionId = useId(regionIdProp);
+ const hasRegionIdProp = regionIdProp != null;
+ const [isRegionMounted, setIsRegionMounted] = React.useState(false);
const ownerState = {
...props,
@@ -203,7 +210,7 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) {
ref,
});
- const [AccordionHeadingSlot, accordionProps] = useSlot('heading', {
+ const [HeadingSlot, headingProps] = useSlot('heading', {
elementType: AccordionHeading,
externalForwardedProps,
className: classes.heading,
@@ -216,25 +223,70 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) {
ownerState,
});
- const [AccordionRegionSlot, accordionRegionProps] = useSlot('region', {
+ // The default Collapse keeps its child mounted unless mounting is explicitly deferred.
+ const isDefaultTransition = TransitionSlot === Collapse;
+ // Mount tracking is only needed when generated aria-controls could point at an unmounted region.
+ // Custom transitions are opaque, so they are treated as unknown until the region ref is set.
+ const usesGeneratedRegionId = !hasRegionIdProp;
+ const isRegionAlwaysMounted =
+ isDefaultTransition && !transitionProps.unmountOnExit && !transitionProps.mountOnEnter;
+ const shouldTrackRegionMount = usesGeneratedRegionId && !isRegionAlwaysMounted;
+
+ const handleRegionMountRef = React.useCallback((node) => {
+ setIsRegionMounted(node != null);
+ }, []);
+
+ const shouldUseGeneratedAriaControls =
+ usesGeneratedRegionId &&
+ (isRegionAlwaysMounted || (isDefaultTransition && expanded) || isRegionMounted);
+
+ const regionExternalSlotProps = slotProps.region;
+ const regionSlotProps = mergeSlotProps(
+ {
+ id: regionId,
+ 'aria-labelledby': summaryId,
+ },
+ regionExternalSlotProps ?? EMPTY,
+ );
+
+ const [RegionSlot, regionProps] = useSlot('region', {
elementType: AccordionRegion,
- externalForwardedProps,
+ externalForwardedProps: {
+ ...externalForwardedProps,
+ slotProps: {
+ ...slotProps,
+ region: regionSlotProps,
+ },
+ },
ownerState,
className: classes.region,
+ ref: shouldTrackRegionMount ? handleRegionMountRef : undefined,
additionalProps: {
- 'aria-labelledby': summary.props.id,
- id: summary.props['aria-controls'],
role: 'region',
},
});
+ const ariaControls = hasRegionIdProp || shouldUseGeneratedAriaControls ? regionId : undefined;
+
+ const contextValue = React.useMemo(
+ () => ({
+ expanded,
+ disabled,
+ disableGutters,
+ toggle: handleChange,
+ summaryId,
+ ariaControls,
+ }),
+ [expanded, disabled, disableGutters, handleChange, summaryId, ariaControls],
+ );
+
return (
-
+
{summary}
-
+
- {children}
+ {regionChildren}
);
diff --git a/packages/mui-material/src/Accordion/Accordion.test.js b/packages/mui-material/src/Accordion/Accordion.test.js
index 751918d05cbbbc..335bbdf1623099 100644
--- a/packages/mui-material/src/Accordion/Accordion.test.js
+++ b/packages/mui-material/src/Accordion/Accordion.test.js
@@ -2,7 +2,7 @@ import * as React from 'react';
import PropTypes from 'prop-types';
import { expect } from 'chai';
import { spy } from 'sinon';
-import { createRenderer, fireEvent, reactMajor, screen } from '@mui/internal-test-utils';
+import { createRenderer, isJsdom, reactMajor, screen, waitFor } 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';
@@ -11,6 +11,7 @@ import Slide from '@mui/material/Slide';
import Grow from '@mui/material/Grow';
import Zoom from '@mui/material/Zoom';
import AccordionSummary from '@mui/material/AccordionSummary';
+import AccordionDetails from '@mui/material/AccordionDetails';
import describeConformance from '../../test/describeConformance';
function NoTransition(props) {
@@ -24,44 +25,69 @@ function NoTransition(props) {
const CustomPaper = React.forwardRef(({ square, ...props }, ref) =>
);
+function WrappedAccordionSummary(props) {
+ return
;
+}
+
+const CustomAccordionRegion = React.forwardRef(function CustomAccordionRegion(
+ { ownerState, ...props },
+ ref,
+) {
+ return
;
+});
+
describe('
', () => {
- const { render } = createRenderer();
-
- const minimalChildren = [
Header];
-
- describeConformance(
{minimalChildren}, () => ({
- classes,
- inheritComponent: Paper,
- render,
- refInstanceof: window.HTMLDivElement,
- muiName: 'MuiAccordion',
- testVariantProps: { variant: 'rounded' },
- slots: {
- transition: {
- testWithElement: null,
+ const { render, renderToString } = createRenderer();
+
+ describeConformance(
+
+ Header
+ Details
+ ,
+ () => ({
+ classes,
+ inheritComponent: Paper,
+ render,
+ refInstanceof: window.HTMLDivElement,
+ muiName: 'MuiAccordion',
+ testVariantProps: { variant: 'rounded' },
+ slots: {
+ transition: {
+ testWithElement: null,
+ },
+ heading: {
+ testWithElement: 'h4',
+ expectedClassName: classes.heading,
+ },
+ root: {
+ expectedClassName: classes.root,
+ testWithElement: CustomPaper,
+ },
+ region: {
+ expectedClassName: classes.region,
+ testWithElement: 'div',
+ },
},
- heading: {
- testWithElement: 'h4',
- expectedClassName: classes.heading,
- },
- root: {
- expectedClassName: classes.root,
- testWithElement: CustomPaper,
- },
- region: {
- expectedClassName: classes.region,
- testWithElement: 'div',
- },
- },
- }));
+ }),
+ );
it('should render and not be controlled', () => {
- const { container } = render(
{minimalChildren});
+ const { container } = render(
+
+ Header
+ Details
+ ,
+ );
expect(container.firstChild).not.to.have.class(classes.expanded);
});
it('should handle defaultExpanded prop', () => {
- const { container } = render(
{minimalChildren});
+ const { container } = render(
+
+ Header
+ Details
+ ,
+ );
expect(container.firstChild).to.have.class(classes.expanded);
});
@@ -69,7 +95,7 @@ describe('
', () => {
render(
Summary
- Hello
+ Hello
,
);
@@ -77,63 +103,95 @@ describe('
', () => {
expect(screen.getByRole('button')).to.have.attribute('aria-expanded', 'false');
});
- it('should be controlled', () => {
- const { container, setProps } = render(
-
- {minimalChildren}
- ,
+ it('should be controlled', async () => {
+ function ControlledAccordion() {
+ const [expanded, setExpanded] = React.useState(true);
+
+ return (
+
setExpanded(newExpanded)}
+ slots={{ transition: NoTransition }}
+ >
+ Header
+ Details
+
+ );
+ }
+
+ const { user } = render(
);
+
+ expect(screen.getByTestId('accordion')).to.have.class(classes.expanded);
+
+ await user.click(screen.getByRole('button', { name: 'Header' }));
+
+ expect(screen.getByTestId('accordion')).not.to.have.class(classes.expanded);
+ expect(screen.getByRole('button', { name: 'Header' })).to.have.attribute(
+ 'aria-expanded',
+ 'false',
);
- const panel = container.firstChild;
- expect(panel).to.have.class(classes.expanded);
- setProps({ expanded: false });
- expect(panel).not.to.have.class(classes.expanded);
});
- it('should call onChange when clicking the summary element', () => {
+ it('should call onChange when clicking the summary element', async () => {
const handleChange = spy();
- render(
+ const { user } = render(
- {minimalChildren}
+ Header
+ Details
,
);
- fireEvent.click(screen.getByText('Header'));
+ await user.click(screen.getByRole('button', { name: 'Header' }));
+
expect(handleChange.callCount).to.equal(1);
});
- it('when controlled should call the onChange', () => {
+ it('when controlled should call the onChange', async () => {
const handleChange = spy();
- render(
+ const { user } = render(
- {minimalChildren}
+ Header
+ Details
,
);
- fireEvent.click(screen.getByText('Header'));
+ await user.click(screen.getByRole('button', { name: 'Header' }));
+
expect(handleChange.callCount).to.equal(1);
expect(handleChange.args[0][1]).to.equal(false);
});
- it('when undefined onChange and controlled should not call the onChange', () => {
- const handleChange = spy();
- const { setProps } = render(
-
- {minimalChildren}
+ it('when onChange is undefined and controlled should not change expansion', async () => {
+ const { user } = render(
+
+ Header
+ Details
,
);
- setProps({ onChange: undefined });
- fireEvent.click(screen.getByText('Header'));
- expect(handleChange.callCount).to.equal(0);
+
+ await user.click(screen.getByRole('button', { name: 'Header' }));
+
+ expect(screen.getByTestId('accordion')).to.have.class(classes.expanded);
+ expect(screen.getByRole('button', { name: 'Header' })).to.have.attribute(
+ 'aria-expanded',
+ 'true',
+ );
});
it('when disabled should have the disabled class', () => {
- const { container } = render({minimalChildren});
+ const { container } = render(
+
+ Header
+ Details
+ ,
+ );
expect(container.firstChild).to.have.class(classes.disabled);
});
- it('should handle the slots.transition prop', () => {
+ it('should handle the slots.transition prop', async () => {
function NoTransitionCollapse(props) {
return props.in ? {props.children}
: null;
}
@@ -145,10 +203,12 @@ describe('', () => {
function CustomContent() {
return Hello
;
}
- const { setProps } = render(
-
+ const { user } = render(
+
-
+
+
+
,
);
@@ -156,12 +216,17 @@ describe('', () => {
expect(screen.getByText('Hello')).toBeVisible();
// Hide the collapse
- setProps({ expanded: false });
+ await user.click(screen.getByRole('button'));
expect(screen.queryByText('Hello')).to.equal(null);
});
it('should handle the `square` prop', () => {
- const { container } = render({minimalChildren});
+ const { container } = render(
+
+ Header
+ Details
+ ,
+ );
expect(container.firstChild).not.toHaveComputedStyle({
borderBottomLeftRadius: '4px',
borderBottomRightRadius: '4px',
@@ -171,7 +236,12 @@ describe('', () => {
});
it('when `square` prop is passed, it should not have the rounded class', () => {
- const { container } = render({minimalChildren});
+ const { container } = render(
+
+ Header
+ Details
+ ,
+ );
expect(container.firstChild).not.to.have.class(classes.rounded);
});
@@ -212,30 +282,48 @@ describe('', () => {
render(
- {null}
+
,
);
});
});
it('should warn when switching from controlled to uncontrolled', () => {
- const { setProps } = render(
+ const { rerender } = render(
- {minimalChildren}
+ Header
+ Details
,
);
- expect(() => setProps({ expanded: undefined })).to.toErrorDev(
+ expect(() =>
+ rerender(
+
+ Header
+ Details
+ ,
+ ),
+ ).to.toErrorDev(
'MUI: A component is changing the controlled expanded state of Accordion to be uncontrolled.',
);
});
it('should warn when switching between uncontrolled to controlled', () => {
- const { setProps } = render(
- {minimalChildren},
+ const { rerender } = render(
+
+ Header
+ Details
+ ,
);
- expect(() => setProps({ expanded: true })).toErrorDev(
+ expect(() =>
+ rerender(
+
+ Header
+ Details
+ ,
+ ),
+ ).toErrorDev(
'MUI: A component is changing the uncontrolled expanded state of Accordion to be controlled.',
);
});
@@ -244,7 +332,8 @@ describe('', () => {
it('should apply properties to the Transition component', () => {
render(
- {minimalChildren}
+ Header
+ Details
,
);
@@ -257,7 +346,7 @@ describe('', () => {
render(
Summary
- Details
+ Details
,
);
@@ -268,7 +357,7 @@ describe('', () => {
render(
Summary
- Details
+ Details
,
);
@@ -311,7 +400,7 @@ describe('', () => {
slotProps={{ transition: { timeout: 400 } }}
>
Summary
- Details
+ Details
,
);
@@ -324,10 +413,557 @@ describe('', () => {
render(
Summary
- Details
+ Details
,
);
expect(screen.getByTestId('region-slot')).to.have.attribute('role', 'list');
});
+
+ describe('ARIA attributes', () => {
+ it('preserves documented id and aria-controls props', () => {
+ render(
+
+
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+ const region = screen.getByRole('region', { hidden: true });
+
+ expect(summary).to.have.attribute('id', 'panel-header');
+ expect(summary).to.have.attribute('aria-controls', 'panel-content');
+ expect(region).to.have.attribute('id', 'panel-content');
+ expect(region).to.have.attribute('aria-labelledby', 'panel-header');
+ });
+
+ it('generates linked ids when id and aria-controls are not provided', async () => {
+ render(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+ const region = screen.getByRole('region', { hidden: true });
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('id');
+ expect(summary).to.have.attribute('aria-controls');
+ expect(region).to.have.attribute('id');
+ expect(region).to.have.attribute('aria-labelledby');
+ });
+
+ expect(summary.getAttribute('id')).to.equal(region.getAttribute('aria-labelledby'));
+ expect(summary.getAttribute('aria-controls')).to.equal(region.getAttribute('id'));
+ });
+
+ it('generates linked ids for a wrapped AccordionSummary', async () => {
+ render(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+ const region = screen.getByRole('region', { hidden: true });
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ expect(region).to.have.attribute('aria-labelledby', summary.getAttribute('id'));
+ });
+ });
+
+ it('ignores relationship props declared inside a wrapped AccordionSummary', async () => {
+ function WrappedSummaryWithRelationshipProps() {
+ return (
+
+ Summary
+
+ );
+ }
+
+ render(
+
+
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+ const region = screen.getByRole('region', { hidden: true });
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ expect(region).to.have.attribute('aria-labelledby', summary.getAttribute('id'));
+ });
+
+ expect(summary).not.to.have.attribute('id', 'inner-summary');
+ expect(summary).not.to.have.attribute('aria-controls', 'inner-region');
+ expect(region).not.to.have.attribute('id', 'inner-region');
+ });
+
+ it('generates unique ids for multiple accordions', async () => {
+ render(
+
+
+ Summary 1
+ Details 1
+
+
+ Summary 2
+ Details 2
+
+
,
+ );
+
+ const [summary1, summary2] = screen.getAllByRole('button');
+
+ await waitFor(() => {
+ expect(summary1).to.have.attribute('id');
+ expect(summary1).to.have.attribute('aria-controls');
+ expect(summary2).to.have.attribute('id');
+ expect(summary2).to.have.attribute('aria-controls');
+ });
+
+ expect(summary1.getAttribute('id')).not.to.equal(summary2.getAttribute('id'));
+ expect(summary1.getAttribute('aria-controls')).not.to.equal(
+ summary2.getAttribute('aria-controls'),
+ );
+ });
+
+ describe.skipIf(!isJsdom())('server-side', () => {
+ it.skipIf(reactMajor < 18)(
+ 'emits complete generated relationships for the documented no-id shape',
+ () => {
+ const { container } = renderToString(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = container.querySelector('button');
+ const region = container.querySelector('[role="region"]');
+
+ expect(summary).to.have.attribute('id');
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ expect(region).to.have.attribute('aria-labelledby', summary.getAttribute('id'));
+ },
+ );
+
+ it('does not emit a dangling aria-controls for wrapped summaries with incomplete custom region ids', () => {
+ const { container } = renderToString(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = container.querySelector('button');
+ const region = container.querySelector('[role="region"]');
+
+ expect(region).not.to.have.attribute('id', 'custom-region');
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ });
+
+ it('does not emit dangling relationship ids from conflicting slot props', () => {
+ const { container } = renderToString(
+
+
+ Summary
+
+ Details
+ ,
+ );
+
+ const summary = container.querySelector('button');
+ const region = container.querySelector('[role="region"]');
+
+ expect(summary).not.to.have.attribute('id', 'slot-summary');
+ expect(summary).not.to.have.attribute('aria-controls', 'slot-region');
+ expect(region).not.to.have.attribute('id', 'slot-region');
+ expect(region).not.to.have.attribute('aria-labelledby', 'slot-summary');
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ });
+
+ it('emits generated aria-controls when unmountOnExit starts expanded', () => {
+ const { container } = renderToString(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = container.querySelector('button');
+ const region = container.querySelector('[role="region"]');
+
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ });
+ });
+
+ it('does not allow slotProps ids to break the relationship', async () => {
+ render(
+
+
+ Summary
+
+ Details
+ ,
+ );
+
+ const summary = screen.getByTestId('summary');
+ const region = screen.getByTestId('region');
+
+ await waitFor(() => {
+ expect(region).to.have.attribute('aria-labelledby', summary.getAttribute('id'));
+ });
+
+ expect(summary).not.to.have.attribute('id', 'slot-summary');
+ expect(summary).not.to.have.attribute('aria-controls', 'slot-region');
+ expect(region).not.to.have.attribute('id', 'slot-region');
+ expect(region).not.to.have.attribute('aria-labelledby', 'slot-summary');
+ expect(summary.getAttribute('aria-controls')).to.equal(region.getAttribute('id'));
+ });
+
+ it('supports function slotProps without letting them replace generated relationships', async () => {
+ render(
+ ({
+ id: 'slot-region',
+ 'aria-labelledby': 'slot-summary',
+ className: isExpanded ? 'expanded-region' : undefined,
+ 'data-testid': 'region',
+ }),
+ }}
+ >
+ ({
+ id: 'slot-summary',
+ 'aria-controls': 'slot-region',
+ className: isExpanded ? 'expanded-summary' : undefined,
+ 'data-testid': 'summary',
+ }),
+ }}
+ >
+ Summary
+
+ Details
+ ,
+ );
+
+ const summary = screen.getByTestId('summary');
+ const region = screen.getByTestId('region');
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ expect(region).to.have.attribute('aria-labelledby', summary.getAttribute('id'));
+ });
+
+ expect(summary).not.to.have.attribute('id', 'slot-summary');
+ expect(summary).not.to.have.attribute('aria-controls', 'slot-region');
+ expect(summary).to.have.class('expanded-summary');
+ expect(region).not.to.have.attribute('id', 'slot-region');
+ expect(region).not.to.have.attribute('aria-labelledby', 'slot-summary');
+ expect(region).to.have.class('expanded-region');
+ });
+
+ it('preserves non-relationship slot props', async () => {
+ const handleSummaryClick = spy();
+ const handleRegionClick = spy();
+ const regionRef = React.createRef();
+
+ const { user } = render(
+
+
+ Summary
+
+ Details
+ ,
+ );
+
+ const summary = screen.getByTestId('summary');
+ const region = screen.getByTestId('region');
+
+ await waitFor(() => {
+ expect(regionRef.current).to.equal(region);
+ });
+
+ expect(summary).to.have.class('custom-summary');
+ expect(summary).to.have.attribute('style').that.includes('margin-top: 2px');
+ expect(region).to.have.class('custom-region');
+ expect(region).to.have.attribute('style').that.includes('margin-top: 1px');
+ expect(region).to.have.attribute('role', 'list');
+
+ await user.click(summary);
+ await user.click(region);
+
+ expect(handleSummaryClick.callCount).to.equal(1);
+ expect(handleRegionClick.callCount).to.equal(1);
+ });
+
+ it('does not emit generated aria-controls when unmountOnExit keeps the region unmounted', () => {
+ render(
+
+ Summary
+ Details
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.to.have.attribute('aria-controls');
+ expect(screen.queryByRole('region')).to.equal(null);
+ });
+
+ it('preserves provided aria-controls when unmountOnExit keeps the region unmounted', () => {
+ render(
+
+
+ Details
+ ,
+ );
+
+ expect(screen.getByRole('button')).to.have.attribute('aria-controls', 'panel-content');
+ expect(screen.queryByRole('region')).to.equal(null);
+ });
+
+ it('emits generated aria-controls after mountOnEnter mounts the region once', async () => {
+ const { user } = render(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+
+ expect(summary).not.to.have.attribute('aria-controls');
+
+ await user.click(summary);
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('aria-controls');
+ });
+
+ const ariaControls = summary.getAttribute('aria-controls');
+
+ await user.click(summary);
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('aria-controls', ariaControls);
+ });
+ });
+
+ it('emits generated aria-controls immediately when mountOnEnter starts expanded', () => {
+ render(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+ const region = screen.getByRole('region', { hidden: true });
+
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ });
+
+ it('does not emit generated aria-controls for a closed custom transition that returns null', () => {
+ render(
+
+ Summary
+ Details
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.to.have.attribute('aria-controls');
+ expect(screen.queryByRole('region')).to.equal(null);
+ });
+
+ it('emits generated aria-controls after a custom transition mounts the region', async () => {
+ const { user } = render(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+
+ await user.click(summary);
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('aria-controls');
+ });
+
+ expect(summary.getAttribute('aria-controls')).to.equal(
+ screen.getByRole('region').getAttribute('id'),
+ );
+ });
+
+ it('removes generated aria-controls after a custom transition unmounts the region', async () => {
+ const { user } = render(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+
+ await waitFor(() => {
+ expect(summary).to.have.attribute('aria-controls');
+ });
+
+ await user.click(summary);
+
+ await waitFor(() => {
+ expect(summary).not.to.have.attribute('aria-controls');
+ });
+
+ expect(screen.queryByRole('region')).to.equal(null);
+ });
+
+ it('passes a single element child to custom transition slots', () => {
+ const handleTransition = spy();
+
+ function CustomTransition(props) {
+ handleTransition(
+ React.Children.count(props.children),
+ React.isValidElement(props.children),
+ );
+
+ return props.in ? props.children : null;
+ }
+
+ render(
+
+ Summary
+ Details
+ ,
+ );
+
+ expect(handleTransition.callCount).to.be.greaterThan(0);
+ handleTransition.getCalls().forEach((call) => {
+ expect(call.args).to.deep.equal([1, true]);
+ });
+ });
+
+ it('preserves provided aria-controls with a closed custom transition that returns null', () => {
+ render(
+
+
+ Details
+ ,
+ );
+
+ expect(screen.getByRole('button')).to.have.attribute('aria-controls', 'panel-content');
+ expect(screen.queryByRole('region')).to.equal(null);
+ });
+
+ it('supports generated relationships with a ref-forwarding custom region slot', async () => {
+ const regionRef = React.createRef();
+ const handleRegionClick = spy();
+
+ const { user } = render(
+
+ Summary
+ Details
+ ,
+ );
+
+ const summary = screen.getByRole('button');
+ const region = screen.getByTestId('region');
+
+ await waitFor(() => {
+ expect(regionRef.current).to.equal(region);
+ expect(summary).to.have.attribute('aria-controls', region.getAttribute('id'));
+ expect(region).to.have.attribute('aria-labelledby', summary.getAttribute('id'));
+ });
+
+ expect(region).to.have.tagName('SECTION');
+ expect(region).to.have.class('custom-region');
+ expect(region).to.have.attribute('style').that.includes('margin-top: 1px');
+ expect(region).to.have.attribute('role', 'list');
+
+ await user.click(region);
+
+ expect(handleRegionClick.callCount).to.equal(1);
+ });
+ });
});
diff --git a/packages/mui-material/src/Accordion/AccordionContext.js b/packages/mui-material/src/Accordion/AccordionContext.js
deleted file mode 100644
index fa7897bcb2cdc7..00000000000000
--- a/packages/mui-material/src/Accordion/AccordionContext.js
+++ /dev/null
@@ -1,14 +0,0 @@
-'use client';
-import * as React from 'react';
-
-/**
- * @ignore - internal component.
- * @type {React.Context<{} | {expanded: boolean, disabled: boolean, toggle: () => void}>}
- */
-const AccordionContext = React.createContext({});
-
-if (process.env.NODE_ENV !== 'production') {
- AccordionContext.displayName = 'AccordionContext';
-}
-
-export default AccordionContext;
diff --git a/packages/mui-material/src/Accordion/AccordionContext.ts b/packages/mui-material/src/Accordion/AccordionContext.ts
new file mode 100644
index 00000000000000..e339287f383459
--- /dev/null
+++ b/packages/mui-material/src/Accordion/AccordionContext.ts
@@ -0,0 +1,45 @@
+'use client';
+import * as React from 'react';
+
+interface AccordionContextValue {
+ expanded: boolean;
+ disabled: boolean;
+ disableGutters: boolean;
+ toggle: (event: React.SyntheticEvent) => void;
+ summaryId?: string | undefined;
+ ariaControls?: string | undefined;
+}
+
+export const NOOP = () => {};
+
+const DEFAULT_CONTEXT_VALUE: AccordionContextValue = {
+ expanded: false,
+ disabled: false,
+ disableGutters: false,
+ toggle: NOOP,
+};
+
+/**
+ * @ignore - internal component.
+ */
+const AccordionContext = React.createContext(DEFAULT_CONTEXT_VALUE);
+
+if (process.env.NODE_ENV !== 'production') {
+ AccordionContext.displayName = 'AccordionContext';
+}
+
+export function useAccordionContext(): AccordionContextValue {
+ const context = React.useContext(AccordionContext);
+ if (context === DEFAULT_CONTEXT_VALUE) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(
+ 'MUI: AccordionSummary should be rendered inside . ' +
+ 'Rendering AccordionSummary outside is deprecated and will no longer be supported in the next major version.',
+ );
+ }
+ }
+
+ return context;
+}
+
+export default AccordionContext;
diff --git a/packages/mui-material/src/AccordionDetails/AccordionDetails.test.js b/packages/mui-material/src/AccordionDetails/AccordionDetails.test.js
index fba6dabf3ed2c5..dfe965990e4be8 100644
--- a/packages/mui-material/src/AccordionDetails/AccordionDetails.test.js
+++ b/packages/mui-material/src/AccordionDetails/AccordionDetails.test.js
@@ -1,5 +1,7 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
+import Accordion from '@mui/material/Accordion';
+import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails, {
accordionDetailsClasses as classes,
} from '@mui/material/AccordionDetails';
@@ -11,7 +13,19 @@ describe('', () => {
describeConformance(Conformance, () => ({
classes,
inheritComponent: 'div',
- render,
+ render: (node) => {
+ const { container, ...other } = render(
+
+ Summary
+ {node}
+ ,
+ );
+
+ return {
+ container: container.querySelector('[role="region"]'),
+ ...other,
+ };
+ },
refInstanceof: window.HTMLDivElement,
muiName: 'MuiAccordionDetails',
skip: ['componentProp', 'themeVariants'],
@@ -19,9 +33,12 @@ describe('', () => {
it('should render a children element', () => {
render(
-
-
- ,
+
+ Summary
+
+
+
+ ,
);
expect(screen.queryByTestId('test-children')).not.to.equal(null);
diff --git a/packages/mui-material/src/AccordionSummary/AccordionSummary.js b/packages/mui-material/src/AccordionSummary/AccordionSummary.js
index 2027b0d8b5cde2..8336de9474e2c6 100644
--- a/packages/mui-material/src/AccordionSummary/AccordionSummary.js
+++ b/packages/mui-material/src/AccordionSummary/AccordionSummary.js
@@ -7,11 +7,14 @@ import { styled } from '../zero-styled';
import memoTheme from '../utils/memoTheme';
import { useDefaultProps } from '../DefaultPropsProvider';
import ButtonBase from '../ButtonBase';
-import AccordionContext from '../Accordion/AccordionContext';
+import { NOOP, useAccordionContext } from '../Accordion/AccordionContext';
import accordionSummaryClasses, {
getAccordionSummaryUtilityClass,
} from './accordionSummaryClasses';
import useSlot from '../utils/useSlot';
+import mergeSlotProps from '../utils/mergeSlotProps';
+
+const EMPTY = {};
const useUtilityClasses = (ownerState) => {
const { classes, expanded, disabled, disableGutters } = ownerState;
@@ -119,11 +122,10 @@ const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref
...other
} = props;
- const { disabled = false, disableGutters, expanded, toggle } = React.useContext(AccordionContext);
- const handleChange = (event) => {
- if (toggle) {
- toggle(event);
- }
+ const accordionContext = useAccordionContext();
+ const { disabled, disableGutters, expanded, toggle, summaryId, ariaControls } = accordionContext;
+ const handleClick = (event) => {
+ toggle(event);
if (onClick) {
onClick(event);
}
@@ -138,12 +140,26 @@ const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref
const classes = useUtilityClasses(ownerState);
+ const rootSlotProps = mergeSlotProps(
+ {
+ id: summaryId,
+ 'aria-controls': ariaControls,
+ },
+ slotProps?.root ?? EMPTY,
+ );
+
const externalForwardedProps = {
slots,
- slotProps,
+ slotProps:
+ accordionContext.toggle === NOOP
+ ? slotProps
+ : {
+ ...slotProps,
+ root: rootSlotProps,
+ },
};
- const [RootSlot, rootSlotProps] = useSlot('root', {
+ const [RootSlot, rootProps] = useSlot('root', {
ref,
shouldForwardComponentProp: true,
className: clsx(classes.root, className),
@@ -165,7 +181,7 @@ const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref
...handlers,
onClick: (event) => {
handlers.onClick?.(event);
- handleChange(event);
+ handleClick(event);
},
}),
});
@@ -185,7 +201,7 @@ const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref
});
return (
-
+
{children}
{expandIcon && (
{expandIcon}
diff --git a/packages/mui-material/src/AccordionSummary/AccordionSummary.test.js b/packages/mui-material/src/AccordionSummary/AccordionSummary.test.js
index 1b12bb071a592f..fde9553fdd131d 100644
--- a/packages/mui-material/src/AccordionSummary/AccordionSummary.test.js
+++ b/packages/mui-material/src/AccordionSummary/AccordionSummary.test.js
@@ -1,11 +1,17 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
-import { act, createRenderer, fireEvent, screen, isJsdom } from '@mui/internal-test-utils';
+import {
+ createRenderer,
+ screen,
+ isJsdom,
+ strictModeDoubleLoggingSuppressed,
+} from '@mui/internal-test-utils';
import AccordionSummary, {
accordionSummaryClasses as classes,
} from '@mui/material/AccordionSummary';
import Accordion from '@mui/material/Accordion';
+import AccordionDetails from '@mui/material/AccordionDetails';
import ButtonBase from '@mui/material/ButtonBase';
import describeConformance from '../../test/describeConformance';
@@ -13,13 +19,31 @@ const CustomButtonBase = React.forwardRef(({ focusVisible, ...props }, ref) => (
));
+const missingAccordionContextWarning = [
+ 'MUI: AccordionSummary should be rendered inside . Rendering AccordionSummary outside is deprecated and will no longer be supported in the next major version.',
+ !strictModeDoubleLoggingSuppressed &&
+ 'MUI: AccordionSummary should be rendered inside . Rendering AccordionSummary outside is deprecated and will no longer be supported in the next major version.',
+];
+
describe('', () => {
const { render } = createRenderer();
describeConformance(, () => ({
classes,
inheritComponent: ButtonBase,
- render,
+ render: (node) => {
+ const { container, ...other } = render(
+
+ {node}
+ Details
+ ,
+ );
+
+ return {
+ container: container.firstChild.firstChild,
+ ...other,
+ };
+ },
refInstanceof: window.HTMLButtonElement,
muiName: 'MuiAccordionSummary',
testVariantProps: { disabled: true },
@@ -39,15 +63,21 @@ describe('', () => {
}));
it('renders the children inside the .content element', () => {
- const { container } = render(The Summary);
+ render(
+
+ The Summary
+ Details
+ ,
+ );
- expect(container.querySelector(`.${classes.content}`)).to.have.text('The Summary');
+ expect(screen.getByText('The Summary')).to.have.class(classes.content);
});
it('when disabled should have disabled class', () => {
render(
+ Details
,
);
@@ -55,77 +85,107 @@ describe('', () => {
});
it('renders the content given in expandIcon prop inside the div.expandIconWrapper', () => {
- const { container } = render();
+ render(
+
+
+ Details
+ ,
+ );
- const expandIconWrapper = container.querySelector(`.${classes.expandIconWrapper}`);
- expect(expandIconWrapper).to.have.text('iconElementContentExample');
+ expect(screen.getByText('iconElementContentExample')).to.have.class(classes.expandIconWrapper);
});
it('when expanded adds the expanded class to the button and .expandIconWrapper', () => {
- const { container } = render(
+ render(
+ Details
,
);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.expanded);
expect(button).to.have.attribute('aria-expanded', 'true');
- expect(container.querySelector(`.${classes.expandIconWrapper}`)).to.have.class(
- classes.expanded,
- );
+ expect(screen.getByText('expand')).to.have.class(classes.expanded);
});
- it('should fire onBlur when the button blurs', () => {
+ it('should fire onBlur when the button blurs', async () => {
const handleBlur = spy();
- render();
- const button = screen.getByRole('button');
+ const { user } = render(
+
+
+ Details
+ ,
+ );
- act(() => {
- button.focus();
- button.blur();
- });
+ await user.tab();
+ await user.tab();
expect(handleBlur.callCount).to.equal(1);
});
- it('should fire onClick callbacks', () => {
+ it('should fire onClick callbacks', async () => {
const handleClick = spy();
- render();
+ const { user } = render(
+
+
+ Details
+ ,
+ );
- screen.getByRole('button').click();
+ await user.click(screen.getByRole('button'));
expect(handleClick.callCount).to.equal(1);
});
- it('fires onChange of the Accordion if clicked', () => {
+ it('preserves id and aria-controls props when rendered outside Accordion', () => {
+ expect(() => {
+ render();
+ }).toWarnDev(missingAccordionContextWarning);
+
+ expect(screen.getByRole('button')).to.have.attribute('id', 'summary-id');
+ expect(screen.getByRole('button')).to.have.attribute('aria-controls', 'region-id');
+ });
+
+ it('preserves slotProps.root id and aria-controls when rendered outside Accordion', () => {
+ expect(() => {
+ render(
+ ,
+ );
+ }).toWarnDev(missingAccordionContextWarning);
+
+ expect(screen.getByRole('button')).to.have.attribute('id', 'summary-id');
+ expect(screen.getByRole('button')).to.have.attribute('aria-controls', 'region-id');
+ });
+
+ it('fires onChange of the Accordion if clicked', async () => {
const handleChange = spy();
- render(
+ const { user } = render(
+ Details
,
);
- act(() => {
- screen.getByRole('button').click();
- });
+ await user.click(screen.getByRole('button'));
expect(handleChange.callCount).to.equal(1);
});
// JSDOM doesn't support :focus-visible
- it.skipIf(isJsdom())('calls onFocusVisible if focused visibly', function test() {
+ it.skipIf(isJsdom())('calls onFocusVisible if focused visibly', async 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();
- });
+ const { user } = render(
+
+
+ Details
+ ,
+ );
+
+ await user.tab();
expect(handleFocusVisible.callCount).to.equal(1);
});
@@ -138,6 +198,7 @@ describe('', () => {
render(
+ Details
,
);