diff --git a/docs/data/material/components/accordion/AccordionExpandDefault.js b/docs/data/material/components/accordion/AccordionExpandDefault.js index c045b66a3ed68d..3f7a050796112b 100644 --- a/docs/data/material/components/accordion/AccordionExpandDefault.js +++ b/docs/data/material/components/accordion/AccordionExpandDefault.js @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionDetails from '@mui/material/AccordionDetails'; @@ -6,36 +5,26 @@ import Typography from '@mui/material/Typography'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; export default function AccordionExpandDefault() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Expanded by default + }> + Delivery options - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Choose standard shipping, scheduled delivery, or pickup based on what is + available for your order. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Header + }> + Gift options - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Add a gift message, choose wrapping, and hide prices on the packing slip. diff --git a/docs/data/material/components/accordion/AccordionExpandDefault.tsx b/docs/data/material/components/accordion/AccordionExpandDefault.tsx index c045b66a3ed68d..3f7a050796112b 100644 --- a/docs/data/material/components/accordion/AccordionExpandDefault.tsx +++ b/docs/data/material/components/accordion/AccordionExpandDefault.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionDetails from '@mui/material/AccordionDetails'; @@ -6,36 +5,26 @@ import Typography from '@mui/material/Typography'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; export default function AccordionExpandDefault() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Expanded by default + }> + Delivery options - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Choose standard shipping, scheduled delivery, or pickup based on what is + available for your order. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Header + }> + Gift options - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Add a gift message, choose wrapping, and hide prices on the packing slip. diff --git a/docs/data/material/components/accordion/AccordionExpandIcon.js b/docs/data/material/components/accordion/AccordionExpandIcon.js index 459816b921a056..5e35d77a4a0ddb 100644 --- a/docs/data/material/components/accordion/AccordionExpandIcon.js +++ b/docs/data/material/components/accordion/AccordionExpandIcon.js @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionDetails from '@mui/material/AccordionDetails'; @@ -7,36 +6,27 @@ import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; export default function AccordionExpandIcon() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Accordion 1 + }> + Performance reports - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Download sales, traffic, and conversion reports from the previous + quarter. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Accordion 2 + }> + Inventory alerts - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Receive notifications when popular items are low in stock or ready to + reorder. diff --git a/docs/data/material/components/accordion/AccordionExpandIcon.tsx b/docs/data/material/components/accordion/AccordionExpandIcon.tsx index 459816b921a056..5e35d77a4a0ddb 100644 --- a/docs/data/material/components/accordion/AccordionExpandIcon.tsx +++ b/docs/data/material/components/accordion/AccordionExpandIcon.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionDetails from '@mui/material/AccordionDetails'; @@ -7,36 +6,27 @@ import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; export default function AccordionExpandIcon() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Accordion 1 + }> + Performance reports - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Download sales, traffic, and conversion reports from the previous + quarter. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Accordion 2 + }> + Inventory alerts - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Receive notifications when popular items are low in stock or ready to + reorder. diff --git a/docs/data/material/components/accordion/AccordionTransition.js b/docs/data/material/components/accordion/AccordionTransition.js index 3dd5ca18842b80..36969ed0f9eed9 100644 --- a/docs/data/material/components/accordion/AccordionTransition.js +++ b/docs/data/material/components/accordion/AccordionTransition.js @@ -9,11 +9,10 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import Fade from '@mui/material/Fade'; export default function AccordionTransition() { - const id = React.useId(); const [expanded, setExpanded] = React.useState(false); - const handleExpansion = () => { - setExpanded((prevExpanded) => !prevExpanded); + const handleExpansion = (_event, isExpanded) => { + setExpanded(isExpanded); }; return ( @@ -43,32 +42,24 @@ export default function AccordionTransition() { }, ]} > - } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > + }> Custom transition using Fade - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + The Fade transition animates opacity when the details are shown or + hidden. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > + }> Default transition using Collapse - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Collapse animates the panel height and is the default transition used by + the Accordion. diff --git a/docs/data/material/components/accordion/AccordionTransition.tsx b/docs/data/material/components/accordion/AccordionTransition.tsx index 3c3ebce28e5d97..ad1699afe40475 100644 --- a/docs/data/material/components/accordion/AccordionTransition.tsx +++ b/docs/data/material/components/accordion/AccordionTransition.tsx @@ -12,11 +12,10 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import Fade from '@mui/material/Fade'; export default function AccordionTransition() { - const id = React.useId(); const [expanded, setExpanded] = React.useState(false); - const handleExpansion = () => { - setExpanded((prevExpanded) => !prevExpanded); + const handleExpansion = (_event: React.SyntheticEvent, isExpanded: boolean) => { + setExpanded(isExpanded); }; return ( @@ -46,32 +45,24 @@ export default function AccordionTransition() { }, ]} > - } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > + }> Custom transition using Fade - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + The Fade transition animates opacity when the details are shown or + hidden. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > + }> Default transition using Collapse - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Collapse animates the panel height and is the default transition used by + the Accordion. diff --git a/docs/data/material/components/accordion/AccordionUsage.js b/docs/data/material/components/accordion/AccordionUsage.js index 26deb503ece3f5..e08fd918c4c2fc 100644 --- a/docs/data/material/components/accordion/AccordionUsage.js +++ b/docs/data/material/components/accordion/AccordionUsage.js @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionActions from '@mui/material/AccordionActions'; import AccordionSummary from '@mui/material/AccordionSummary'; @@ -8,50 +7,43 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import Button from '@mui/material/Button'; export default function AccordionUsage() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Accordion 1 + }> + Delivery details - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + + Choose your preferred delivery method, add delivery instructions, and + update your saved address. + - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Accordion 2 + }> + Payment method - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + + Update your billing information, select a default card, or add a new + payment method. + - } - aria-controls={`${id}-panel3-content`} - id={`${id}-panel3-header`} - > - Accordion Actions + }> + Order updates - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + + Get shipment status by email, push notification, or SMS when an order + changes. + - +
diff --git a/docs/data/material/components/accordion/AccordionUsage.tsx b/docs/data/material/components/accordion/AccordionUsage.tsx index 26deb503ece3f5..e08fd918c4c2fc 100644 --- a/docs/data/material/components/accordion/AccordionUsage.tsx +++ b/docs/data/material/components/accordion/AccordionUsage.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionActions from '@mui/material/AccordionActions'; import AccordionSummary from '@mui/material/AccordionSummary'; @@ -8,50 +7,43 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import Button from '@mui/material/Button'; export default function AccordionUsage() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Accordion 1 + }> + Delivery details - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + + Choose your preferred delivery method, add delivery instructions, and + update your saved address. + - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Accordion 2 + }> + Payment method - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + + Update your billing information, select a default card, or add a new + payment method. + - } - aria-controls={`${id}-panel3-content`} - id={`${id}-panel3-header`} - > - Accordion Actions + }> + Order updates - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + + Get shipment status by email, push notification, or SMS when an order + changes. + - +
diff --git a/docs/data/material/components/accordion/ControlledAccordions.js b/docs/data/material/components/accordion/ControlledAccordions.js index d9a0067cb62a5b..54b96b5d51529a 100644 --- a/docs/data/material/components/accordion/ControlledAccordions.js +++ b/docs/data/material/components/accordion/ControlledAccordions.js @@ -8,87 +8,70 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; export default function ControlledAccordions() { const [expanded, setExpanded] = React.useState(false); - const handleChange = (panel) => (event, isExpanded) => { + const handleChange = (panel) => (_event, isExpanded) => { setExpanded(isExpanded ? panel : false); }; return (
- } - aria-controls="panel1bh-content" - id="panel1bh-header" - > + }> - General settings + Account - I am an accordion + Primary contact and timezone - Nulla facilisi. Phasellus sollicitudin nulla et quam mattis feugiat. - Aliquam eget maximus est, id dignissim quam. + Choose the contact name and email that appear on invoices and customer + emails. - } - aria-controls="panel2bh-content" - id="panel2bh-header" - > + }> - Users + Team access - You are currently not an owner + 3 admins, 12 members - Donec placerat, lectus sed mattis semper, neque lectus feugiat lectus, - varius pulvinar diam eros in elit. Pellentesque convallis laoreet - laoreet. + Invite teammates, assign roles, and choose who can approve billing + changes. - } - aria-controls="panel3bh-content" - id="panel3bh-header" - > + }> - Advanced settings + Order routing - Filtering has been entirely disabled for whole web server + Routes to the closest warehouse - Nunc vitae orci ultricies, auctor nunc in, volutpat nisl. Integer sit - amet egestas eros, vitae egestas augue. Duis vel est augue. + Set fulfillment rules so orders ship from the warehouse with the best + stock and delivery speed. - } - aria-controls="panel4bh-content" - id="panel4bh-header" - > + }> - Personal data + Data export - Nunc vitae orci ultricies, auctor nunc in, volutpat nisl. Integer sit - amet egestas eros, vitae egestas augue. Duis vel est augue. + Generate a downloadable archive of account activity, order history, and + customer records. diff --git a/docs/data/material/components/accordion/ControlledAccordions.tsx b/docs/data/material/components/accordion/ControlledAccordions.tsx index 1eaa7c910ea988..8458b56f33acf6 100644 --- a/docs/data/material/components/accordion/ControlledAccordions.tsx +++ b/docs/data/material/components/accordion/ControlledAccordions.tsx @@ -9,87 +9,70 @@ export default function ControlledAccordions() { const [expanded, setExpanded] = React.useState(false); const handleChange = - (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { + (panel: string) => (_event: React.SyntheticEvent, isExpanded: boolean) => { setExpanded(isExpanded ? panel : false); }; return (
- } - aria-controls="panel1bh-content" - id="panel1bh-header" - > + }> - General settings + Account - I am an accordion + Primary contact and timezone - Nulla facilisi. Phasellus sollicitudin nulla et quam mattis feugiat. - Aliquam eget maximus est, id dignissim quam. + Choose the contact name and email that appear on invoices and customer + emails. - } - aria-controls="panel2bh-content" - id="panel2bh-header" - > + }> - Users + Team access - You are currently not an owner + 3 admins, 12 members - Donec placerat, lectus sed mattis semper, neque lectus feugiat lectus, - varius pulvinar diam eros in elit. Pellentesque convallis laoreet - laoreet. + Invite teammates, assign roles, and choose who can approve billing + changes. - } - aria-controls="panel3bh-content" - id="panel3bh-header" - > + }> - Advanced settings + Order routing - Filtering has been entirely disabled for whole web server + Routes to the closest warehouse - Nunc vitae orci ultricies, auctor nunc in, volutpat nisl. Integer sit - amet egestas eros, vitae egestas augue. Duis vel est augue. + Set fulfillment rules so orders ship from the warehouse with the best + stock and delivery speed. - } - aria-controls="panel4bh-content" - id="panel4bh-header" - > + }> - Personal data + Data export - Nunc vitae orci ultricies, auctor nunc in, volutpat nisl. Integer sit - amet egestas eros, vitae egestas augue. Duis vel est augue. + Generate a downloadable archive of account activity, order history, and + customer records. diff --git a/docs/data/material/components/accordion/CustomizedAccordions.js b/docs/data/material/components/accordion/CustomizedAccordions.js index 0390578a7d5c2d..0fb821eb69cf6f 100644 --- a/docs/data/material/components/accordion/CustomizedAccordions.js +++ b/docs/data/material/components/accordion/CustomizedAccordions.js @@ -48,48 +48,42 @@ const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({ export default function CustomizedAccordions() { const [expanded, setExpanded] = React.useState('panel1'); - const handleChange = (panel) => (event, newExpanded) => { + const handleChange = (panel) => (_event, newExpanded) => { setExpanded(newExpanded ? panel : false); }; return (
- - Collapsible Group Item #1 + + Account setup - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. Lorem ipsum dolor - sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, - sit amet blandit leo lobortis eget. + Connect your store, configure checkout, and choose which notifications + customers receive after they place an order. - - Collapsible Group Item #2 + + Billing alerts - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. Lorem ipsum dolor - sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, - sit amet blandit leo lobortis eget. + Review recent invoices, update your tax details, and choose who receives + payment reminders. - - Collapsible Group Item #3 + + Team permissions - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. Lorem ipsum dolor - sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, - sit amet blandit leo lobortis eget. + Invite collaborators, assign store roles, and limit access to sensitive + customer information. diff --git a/docs/data/material/components/accordion/CustomizedAccordions.tsx b/docs/data/material/components/accordion/CustomizedAccordions.tsx index 16707f15eb63cd..1b960b6c99c7ff 100644 --- a/docs/data/material/components/accordion/CustomizedAccordions.tsx +++ b/docs/data/material/components/accordion/CustomizedAccordions.tsx @@ -50,48 +50,42 @@ export default function CustomizedAccordions() { const [expanded, setExpanded] = React.useState('panel1'); const handleChange = - (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => { + (panel: string) => (_event: React.SyntheticEvent, newExpanded: boolean) => { setExpanded(newExpanded ? panel : false); }; return (
- - Collapsible Group Item #1 + + Account setup - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. Lorem ipsum dolor - sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, - sit amet blandit leo lobortis eget. + Connect your store, configure checkout, and choose which notifications + customers receive after they place an order. - - Collapsible Group Item #2 + + Billing alerts - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. Lorem ipsum dolor - sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, - sit amet blandit leo lobortis eget. + Review recent invoices, update your tax details, and choose who receives + payment reminders. - - Collapsible Group Item #3 + + Team permissions - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. Lorem ipsum dolor - sit amet, consectetur adipiscing elit. Suspendisse malesuada lacus ex, - sit amet blandit leo lobortis eget. + Invite collaborators, assign store roles, and limit access to sensitive + customer information. diff --git a/docs/data/material/components/accordion/DisabledAccordion.js b/docs/data/material/components/accordion/DisabledAccordion.js index ef1cf954db88f2..ae83aa4edc28d2 100644 --- a/docs/data/material/components/accordion/DisabledAccordion.js +++ b/docs/data/material/components/accordion/DisabledAccordion.js @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionDetails from '@mui/material/AccordionDetails'; @@ -6,46 +5,33 @@ import Typography from '@mui/material/Typography'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; export default function DisabledAccordion() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Accordion 1 + }> + Active subscription - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Review your plan, renewal date, and the number of seats included in your + subscription. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Accordion 2 + }> + Billing history - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Download invoices, review payment status, or update the billing contact + for your account. - } - aria-controls={`${id}-panel3-content`} - id={`${id}-panel3-header`} - > - Disabled Accordion + }> + Cancellation unavailable
diff --git a/docs/data/material/components/accordion/DisabledAccordion.tsx b/docs/data/material/components/accordion/DisabledAccordion.tsx index ef1cf954db88f2..ae83aa4edc28d2 100644 --- a/docs/data/material/components/accordion/DisabledAccordion.tsx +++ b/docs/data/material/components/accordion/DisabledAccordion.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionDetails from '@mui/material/AccordionDetails'; @@ -6,46 +5,33 @@ import Typography from '@mui/material/Typography'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; export default function DisabledAccordion() { - const id = React.useId(); return (
- } - aria-controls={`${id}-panel1-content`} - id={`${id}-panel1-header`} - > - Accordion 1 + }> + Active subscription - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Review your plan, renewal date, and the number of seats included in your + subscription. - } - aria-controls={`${id}-panel2-content`} - id={`${id}-panel2-header`} - > - Accordion 2 + }> + Billing history - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse - malesuada lacus ex, sit amet blandit leo lobortis eget. + Download invoices, review payment status, or update the billing contact + for your account. - } - aria-controls={`${id}-panel3-content`} - id={`${id}-panel3-header`} - > - Disabled Accordion + }> + Cancellation unavailable
diff --git a/docs/data/material/components/accordion/accordion.md b/docs/data/material/components/accordion/accordion.md index a342c0df8489d4..02d0eb33b65b52 100644 --- a/docs/data/material/components/accordion/accordion.md +++ b/docs/data/material/components/accordion/accordion.md @@ -14,29 +14,48 @@ githubSource: packages/mui-material/src/Accordion {{"component": "@mui/internal-core-docs/ComponentLinkHeader"}} -## Introduction - -The Material UI Accordion component includes several complementary utility components to handle various use cases: +:::info +This component is no longer documented in the [Material Design guidelines](https://m2.material.io/), but Material UI will continue to support it. +::: -- Accordion: the wrapper for grouping related components. -- Accordion Summary: the wrapper for the Accordion header, which expands or collapses the content when clicked. -- Accordion Details: the wrapper for the Accordion content. -- Accordion Actions: an optional wrapper that groups a set of buttons. +## Anatomy {{"demo": "AccordionUsage.js", "bg": true}} -:::info -This component is no longer documented in the [Material Design guidelines](https://m2.material.io/), but Material UI will continue to support it. -::: +The Accordion components form a header and panel: -## Basics +- `Accordion`: groups `AccordionSummary` and `AccordionDetails`. Renders a `
` element. +- `AccordionSummary`: a trigger that expands or collapses the panel. Renders an `h3` containing a ` + + +; ``` +## 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 + + Notification preferences + + Choose which account alerts are sent by email, SMS, or push notification. + + + + + + +``` ### 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 + + Shipping methods + + Choose how quickly your order should arrive and whether you want delivery updates + by email or SMS. + + +``` + +### 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 - - } - aria-controls="panel1-content" - id="panel1-header" - > - Accordion - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse malesuada - lacus ex, sit amet blandit leo lobortis eget. - - -``` +{{"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 - - - Header - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - - -``` - -## Anatomy - -The Accordion component consists of a root `
` 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( + + + Summary + + 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( + + + Summary + + 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( + + + Summary + + 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 , );