diff --git a/docs/data/base/components/accordion/accordion.md b/docs/data/base/components/accordion/accordion.md
new file mode 100644
index 0000000000..96b8af2955
--- /dev/null
+++ b/docs/data/base/components/accordion/accordion.md
@@ -0,0 +1,13 @@
+---
+productId: base-ui
+title: React Accordion components
+components: AccordionRoot, AccordionSection, AccordionHeading, AccordionTrigger, AccordionPanel
+hooks: useAccordionRoot
+githubLabel: 'component: accordion'
+waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
+packageName: '@base_ui/react'
+---
+
+# Accordion
+
+
Accordion is a stacked set of interactive headings that each reveal an associated section of content.
diff --git a/docs/data/base/pages.ts b/docs/data/base/pages.ts
index 31b0ae6266..16a72c6be8 100644
--- a/docs/data/base/pages.ts
+++ b/docs/data/base/pages.ts
@@ -34,6 +34,7 @@ const pages: readonly MuiPage[] = [
pathname: '/base-ui/components/data-display',
subheader: 'data-display',
children: [
+ { pathname: '/base-ui/react-accordion', title: 'Accordion' },
{ pathname: '/base-ui/react-collapsible', title: 'Collapsible' },
{ pathname: '/base-ui/react-popover', title: 'Popover' },
{ pathname: '/base-ui/react-preview-card', title: 'Preview Card' },
diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js
index c0b8300f64..e5a4e2d56c 100644
--- a/docs/data/base/pagesApi.js
+++ b/docs/data/base/pagesApi.js
@@ -1,4 +1,24 @@
module.exports = [
+ {
+ pathname: '/base-ui/react-accordion/components-api/#accordion-heading',
+ title: 'AccordionHeading',
+ },
+ {
+ pathname: '/base-ui/react-accordion/components-api/#accordion-panel',
+ title: 'AccordionPanel',
+ },
+ {
+ pathname: '/base-ui/react-accordion/components-api/#accordion-root',
+ title: 'AccordionRoot',
+ },
+ {
+ pathname: '/base-ui/react-accordion/components-api/#accordion-section',
+ title: 'AccordionSection',
+ },
+ {
+ pathname: '/base-ui/react-accordion/components-api/#accordion-trigger',
+ title: 'AccordionTrigger',
+ },
{
pathname: '/base-ui/react-alert-dialog/components-api/#alert-dialog-backdrop',
title: 'AlertDialogBackdrop',
@@ -334,6 +354,10 @@ module.exports = [
pathname: '/base-ui/react-tooltip/components-api/#tooltip-trigger',
title: 'TooltipTrigger',
},
+ {
+ pathname: '/base-ui/react-accordion/hooks-api/#use-accordion-root',
+ title: 'useAccordionRoot',
+ },
{
pathname: '/base-ui/react-autocomplete/hooks-api/#use-autocomplete',
title: 'useAutocomplete',
diff --git a/docs/pages/base-ui/api/accordion-heading.json b/docs/pages/base-ui/api/accordion-heading.json
new file mode 100644
index 0000000000..3df8bb505b
--- /dev/null
+++ b/docs/pages/base-ui/api/accordion-heading.json
@@ -0,0 +1,19 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "AccordionHeading",
+ "imports": [
+ "import * as Accordion from '@base_ui/react/Accordion';\nconst AccordionHeading = Accordion.Heading;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "AccordionHeading",
+ "forwardsRefTo": "HTMLHeadingElement",
+ "filename": "/packages/mui-base/src/Accordion/Heading/AccordionHeading.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/accordion-panel.json b/docs/pages/base-ui/api/accordion-panel.json
new file mode 100644
index 0000000000..a639f5eb23
--- /dev/null
+++ b/docs/pages/base-ui/api/accordion-panel.json
@@ -0,0 +1,23 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "htmlHidden": {
+ "type": { "name": "enum", "description": "'hidden'
| 'until-found'" },
+ "default": "'hidden'"
+ },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "AccordionPanel",
+ "imports": [
+ "import * as Accordion from '@base_ui/react/Accordion';\nconst AccordionPanel = Accordion.Panel;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "AccordionPanel",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/Accordion/Panel/AccordionPanel.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/accordion-root.json b/docs/pages/base-ui/api/accordion-root.json
new file mode 100644
index 0000000000..dfc9fd04c3
--- /dev/null
+++ b/docs/pages/base-ui/api/accordion-root.json
@@ -0,0 +1,29 @@
+{
+ "props": {
+ "animated": { "type": { "name": "bool" }, "default": "true" },
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "defaultValue": {
+ "type": { "name": "arrayOf", "description": "Array<number
| string>" },
+ "default": "0"
+ },
+ "disabled": { "type": { "name": "bool" }, "default": "false" },
+ "onOpenChange": { "type": { "name": "func" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } },
+ "value": {
+ "type": { "name": "arrayOf", "description": "Array<number
| string>" }
+ }
+ },
+ "name": "AccordionRoot",
+ "imports": [
+ "import * as Accordion from '@base_ui/react/Accordion';\nconst AccordionRoot = Accordion.Root;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "AccordionRoot",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/Accordion/Root/AccordionRoot.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/accordion-section.json b/docs/pages/base-ui/api/accordion-section.json
new file mode 100644
index 0000000000..6ae6c7f4b9
--- /dev/null
+++ b/docs/pages/base-ui/api/accordion-section.json
@@ -0,0 +1,21 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "disabled": { "type": { "name": "bool" }, "default": "false" },
+ "onOpenChange": { "type": { "name": "func" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "AccordionSection",
+ "imports": [
+ "import * as Accordion from '@base_ui/react/Accordion';\nconst AccordionSection = Accordion.Section;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "AccordionSection",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/Accordion/Section/AccordionSection.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/accordion-trigger.json b/docs/pages/base-ui/api/accordion-trigger.json
new file mode 100644
index 0000000000..b5017e4879
--- /dev/null
+++ b/docs/pages/base-ui/api/accordion-trigger.json
@@ -0,0 +1,19 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "AccordionTrigger",
+ "imports": [
+ "import * as Accordion from '@base_ui/react/Accordion';\nconst AccordionTrigger = Accordion.Trigger;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "AccordionTrigger",
+ "forwardsRefTo": "HTMLButtonElement",
+ "filename": "/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/use-accordion-root.json b/docs/pages/base-ui/api/use-accordion-root.json
new file mode 100644
index 0000000000..53bd7e2aef
--- /dev/null
+++ b/docs/pages/base-ui/api/use-accordion-root.json
@@ -0,0 +1,8 @@
+{
+ "parameters": {},
+ "returnValue": {},
+ "name": "useAccordionRoot",
+ "filename": "/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts",
+ "imports": ["import { useAccordionRoot } from '@base_ui/react/Accordion';"],
+ "demos": ""
+}
diff --git a/docs/pages/base-ui/react-accordion/[docsTab]/index.js b/docs/pages/base-ui/react-accordion/[docsTab]/index.js
new file mode 100644
index 0000000000..186528f93a
--- /dev/null
+++ b/docs/pages/base-ui/react-accordion/[docsTab]/index.js
@@ -0,0 +1,92 @@
+import * as React from 'react';
+import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2';
+import AppFrame from 'docs/src/modules/components/AppFrame';
+import * as pageProps from 'docs-base/data/base/components/accordion/accordion.md?@mui/markdown';
+import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
+import AccordionHeadingApiJsonPageContent from '../../api/accordion-heading.json';
+import AccordionPanelApiJsonPageContent from '../../api/accordion-panel.json';
+import AccordionRootApiJsonPageContent from '../../api/accordion-root.json';
+import AccordionSectionApiJsonPageContent from '../../api/accordion-section.json';
+import AccordionTriggerApiJsonPageContent from '../../api/accordion-trigger.json';
+import useAccordionRootApiJsonPageContent from '../../api/use-accordion-root.json';
+
+export default function Page(props) {
+ const { userLanguage, ...other } = props;
+ return ;
+}
+
+Page.getLayout = (page) => {
+ return {page};
+};
+
+export const getStaticPaths = () => {
+ return {
+ paths: [{ params: { docsTab: 'components-api' } }, { params: { docsTab: 'hooks-api' } }],
+ fallback: false, // can also be true or 'blocking'
+ };
+};
+
+export const getStaticProps = () => {
+ const AccordionHeadingApiReq = require.context(
+ 'docs-base/translations/api-docs/accordion-heading',
+ false,
+ /\.\/accordion-heading.*.json$/,
+ );
+ const AccordionHeadingApiDescriptions = mapApiPageTranslations(AccordionHeadingApiReq);
+
+ const AccordionPanelApiReq = require.context(
+ 'docs-base/translations/api-docs/accordion-panel',
+ false,
+ /\.\/accordion-panel.*.json$/,
+ );
+ const AccordionPanelApiDescriptions = mapApiPageTranslations(AccordionPanelApiReq);
+
+ const AccordionRootApiReq = require.context(
+ 'docs-base/translations/api-docs/accordion-root',
+ false,
+ /\.\/accordion-root.*.json$/,
+ );
+ const AccordionRootApiDescriptions = mapApiPageTranslations(AccordionRootApiReq);
+
+ const AccordionSectionApiReq = require.context(
+ 'docs-base/translations/api-docs/accordion-section',
+ false,
+ /\.\/accordion-section.*.json$/,
+ );
+ const AccordionSectionApiDescriptions = mapApiPageTranslations(AccordionSectionApiReq);
+
+ const AccordionTriggerApiReq = require.context(
+ 'docs-base/translations/api-docs/accordion-trigger',
+ false,
+ /\.\/accordion-trigger.*.json$/,
+ );
+ const AccordionTriggerApiDescriptions = mapApiPageTranslations(AccordionTriggerApiReq);
+
+ const useAccordionRootApiReq = require.context(
+ 'docs-base/translations/api-docs/use-accordion-root',
+ false,
+ /\.\/use-accordion-root.*.json$/,
+ );
+ const useAccordionRootApiDescriptions = mapApiPageTranslations(useAccordionRootApiReq);
+
+ return {
+ props: {
+ componentsApiDescriptions: {
+ AccordionHeading: AccordionHeadingApiDescriptions,
+ AccordionPanel: AccordionPanelApiDescriptions,
+ AccordionRoot: AccordionRootApiDescriptions,
+ AccordionSection: AccordionSectionApiDescriptions,
+ AccordionTrigger: AccordionTriggerApiDescriptions,
+ },
+ componentsApiPageContents: {
+ AccordionHeading: AccordionHeadingApiJsonPageContent,
+ AccordionPanel: AccordionPanelApiJsonPageContent,
+ AccordionRoot: AccordionRootApiJsonPageContent,
+ AccordionSection: AccordionSectionApiJsonPageContent,
+ AccordionTrigger: AccordionTriggerApiJsonPageContent,
+ },
+ hooksApiDescriptions: { useAccordionRoot: useAccordionRootApiDescriptions },
+ hooksApiPageContents: { useAccordionRoot: useAccordionRootApiJsonPageContent },
+ },
+ };
+};
diff --git a/docs/pages/base-ui/react-accordion/index.js b/docs/pages/base-ui/react-accordion/index.js
new file mode 100644
index 0000000000..042302ada2
--- /dev/null
+++ b/docs/pages/base-ui/react-accordion/index.js
@@ -0,0 +1,13 @@
+import * as React from 'react';
+import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2';
+import AppFrame from 'docs/src/modules/components/AppFrame';
+import * as pageProps from 'docs-base/data/base/components/accordion/accordion.md?@mui/markdown';
+
+export default function Page(props) {
+ const { userLanguage, ...other } = props;
+ return ;
+}
+
+Page.getLayout = (page) => {
+ return {page};
+};
diff --git a/docs/pages/experiments/accordion.tsx b/docs/pages/experiments/accordion.tsx
new file mode 100644
index 0000000000..94e537a392
--- /dev/null
+++ b/docs/pages/experiments/accordion.tsx
@@ -0,0 +1,191 @@
+import * as React from 'react';
+import Check from '@mui/icons-material/Check';
+import { useTheme } from '@mui/system';
+import * as Checkbox from '@base_ui/react/Checkbox';
+import * as Accordion from '@base_ui/react/Accordion';
+
+export default function App() {
+ const [openMultiple, setOpenMultiple] = React.useState(true);
+ const [val, setVal] = React.useState(['one']);
+ return (
+
+
multiple `Accordion.Section`s can be open at the same time:
+
+
+
+
+
+
+
+
+
Uncontrolled
+
+
+
+
+ Trigger 1
+
+
+ This is the contents of Accordion.Panel 1
+
+
+
+
+
+ Trigger 2
+
+
+ This is the contents of Accordion.Panel 2
+
+
+
+
+
+
+ Trigger 3
+
+
+
+ This is the contents of Accordion.Panel 3
+
+
+
+
+
+ Trigger 4
+
+
+ This is the contents of Accordion.Panel 4
+
+
+
+
+
+ Trigger 5
+
+
+ This is the contents of Accordion.Panel 5
+
+
+
+
+
+
+
Controlled
+
+
+
+
+ Trigger 1
+
+
+ This is the contents of Accordion.Panel 1, the value is "one"
+
+
+
+
+
+ Trigger 2
+
+
+ This is the contents of Accordion.Panel 2, the value is "two"
+
+
+
+
+
+ Trigger 3
+
+
+ This is the contents of Accordion.Panel 3, the value is "three"
+
+
+
+
+
+ );
+}
+
+const grey = {
+ 100: '#E5EAF2',
+ 300: '#C7D0DD',
+ 500: '#9DA8B7',
+ 600: '#6B7A90',
+ 800: '#303740',
+ 900: '#1C2025',
+};
+
+function useIsDarkMode() {
+ const theme = useTheme();
+ return theme.palette.mode === 'dark';
+}
+
+function Styles() {
+ // Replace this with your app logic for determining dark mode
+ const isDarkMode = useIsDarkMode();
+ return (
+
+ );
+}
diff --git a/docs/translations/api-docs/accordion-heading/accordion-heading.json b/docs/translations/api-docs/accordion-heading/accordion-heading.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/translations/api-docs/accordion-heading/accordion-heading.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/accordion-panel/accordion-panel.json b/docs/translations/api-docs/accordion-panel/accordion-panel.json
new file mode 100644
index 0000000000..722a5c7ab1
--- /dev/null
+++ b/docs/translations/api-docs/accordion-panel/accordion-panel.json
@@ -0,0 +1,11 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "htmlHidden": { "description": "The hidden state when closed" },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/accordion-root/accordion-root.json b/docs/translations/api-docs/accordion-root/accordion-root.json
new file mode 100644
index 0000000000..0b62be1d1c
--- /dev/null
+++ b/docs/translations/api-docs/accordion-root/accordion-root.json
@@ -0,0 +1,23 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "animated": {
+ "description": "If true
, the component supports CSS/JS-based animations and transitions."
+ },
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "defaultValue": {
+ "description": "The default value representing the currently open Accordion.Section
This is the uncontrolled counterpart of value
."
+ },
+ "disabled": { "description": "If true
, the component is disabled." },
+ "onOpenChange": {
+ "description": "Callback fired when an Accordion section is opened or closed. The value representing the involved section is provided as an argument."
+ },
+ "render": { "description": "A function to customize rendering of the component." },
+ "value": {
+ "description": "The value of the currently open Accordion.Section
This is the controlled counterpart of defaultValue
."
+ }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/accordion-section/accordion-section.json b/docs/translations/api-docs/accordion-section/accordion-section.json
new file mode 100644
index 0000000000..121ef90810
--- /dev/null
+++ b/docs/translations/api-docs/accordion-section/accordion-section.json
@@ -0,0 +1,12 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "disabled": { "description": "If true
, the component is disabled." },
+ "onOpenChange": { "description": "Callback fired when the Collapsible is opened or closed." },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/accordion-trigger/accordion-trigger.json b/docs/translations/api-docs/accordion-trigger/accordion-trigger.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/translations/api-docs/accordion-trigger/accordion-trigger.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/use-accordion-root/use-accordion-root.json b/docs/translations/api-docs/use-accordion-root/use-accordion-root.json
new file mode 100644
index 0000000000..e3eb65c6e4
--- /dev/null
+++ b/docs/translations/api-docs/use-accordion-root/use-accordion-root.json
@@ -0,0 +1 @@
+{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} }
diff --git a/packages/mui-base/src/Accordion/Heading/AccordionHeading.test.tsx b/packages/mui-base/src/Accordion/Heading/AccordionHeading.test.tsx
new file mode 100644
index 0000000000..6a219aa065
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Heading/AccordionHeading.test.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+// import { expect } from 'chai';
+// import { spy } from 'sinon';
+import { createRenderer /* , act */ } from '@mui/internal-test-utils';
+import * as Accordion from '@base_ui/react/Accordion';
+import * as Collapsible from '@base_ui/react/Collapsible';
+import { describeConformance } from '../../../test/describeConformance';
+
+const { AccordionRootContext, AccordionSectionContext } = Accordion;
+
+const { CollapsibleContext } = Collapsible;
+
+const accordionRootContextValue: Accordion.Root.Context = {
+ accordionSectionRefs: { current: [] },
+ animated: false,
+ direction: 'ltr',
+ disabled: false,
+ handleOpenChange() {},
+ orientation: 'vertical',
+ ownerState: {
+ value: [0],
+ disabled: false,
+ orientation: 'vertical',
+ },
+ value: [0],
+};
+
+const accordionSectionContextValue: Accordion.Section.Context = {
+ open: true,
+ ownerState: {
+ value: [0],
+ disabled: false,
+ index: 0,
+ open: true,
+ orientation: 'vertical',
+ transitionStatus: undefined,
+ },
+};
+
+const collapsibleContextValue: Collapsible.Root.Context = {
+ animated: false,
+ contentId: ':content:',
+ disabled: false,
+ mounted: true,
+ open: true,
+ setContentId() {},
+ setMounted() {},
+ setOpen() {},
+ transitionStatus: undefined,
+ ownerState: {
+ open: true,
+ disabled: false,
+ transitionStatus: undefined,
+ },
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ inheritComponent: 'h3',
+ render: (node) => {
+ const { container, ...other } = render(
+
+
+
+ {node}
+
+
+ ,
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLHeadingElement,
+ }));
+});
diff --git a/packages/mui-base/src/Accordion/Heading/AccordionHeading.tsx b/packages/mui-base/src/Accordion/Heading/AccordionHeading.tsx
new file mode 100644
index 0000000000..78b80b620a
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Heading/AccordionHeading.tsx
@@ -0,0 +1,53 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import type { AccordionSection } from '../Section/AccordionSection';
+import { useAccordionSectionContext } from '../Section/AccordionSectionContext';
+import { accordionStyleHookMapping } from '../Section/styleHooks';
+
+const AccordionHeading = React.forwardRef(function AccordionHeading(
+ props: AccordionHeading.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { ownerState } = useAccordionSectionContext();
+
+ const { renderElement } = useComponentRenderer({
+ render: render ?? 'h3',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: accordionStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+AccordionHeading.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { AccordionHeading };
+
+export namespace AccordionHeading {
+ export interface Props extends BaseUIComponentProps<'h3', AccordionSection.OwnerState> {}
+}
diff --git a/packages/mui-base/src/Accordion/Panel/AccordionPanel.test.tsx b/packages/mui-base/src/Accordion/Panel/AccordionPanel.test.tsx
new file mode 100644
index 0000000000..f03b917510
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Panel/AccordionPanel.test.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+// import { expect } from 'chai';
+// import { spy } from 'sinon';
+import { createRenderer /* , act */ } from '@mui/internal-test-utils';
+import * as Accordion from '@base_ui/react/Accordion';
+import * as Collapsible from '@base_ui/react/Collapsible';
+import { describeConformance } from '../../../test/describeConformance';
+
+const { AccordionRootContext, AccordionSectionContext } = Accordion;
+
+const { CollapsibleContext } = Collapsible;
+
+const accordionRootContextValue: Accordion.Root.Context = {
+ accordionSectionRefs: { current: [] },
+ animated: false,
+ direction: 'ltr',
+ disabled: false,
+ handleOpenChange() {},
+ orientation: 'vertical',
+ ownerState: {
+ value: [0],
+ disabled: false,
+ orientation: 'vertical',
+ },
+ value: [0],
+};
+
+const accordionSectionContextValue: Accordion.Section.Context = {
+ open: true,
+ ownerState: {
+ value: [0],
+ disabled: false,
+ index: 0,
+ open: true,
+ orientation: 'vertical',
+ transitionStatus: undefined,
+ },
+};
+
+const collapsibleContextValue: Collapsible.Root.Context = {
+ animated: false,
+ contentId: ':content:',
+ disabled: false,
+ mounted: true,
+ open: true,
+ setContentId() {},
+ setMounted() {},
+ setOpen() {},
+ transitionStatus: undefined,
+ ownerState: {
+ open: true,
+ disabled: false,
+ transitionStatus: undefined,
+ },
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ inheritComponent: 'div',
+ render: (node) => {
+ const { container, ...other } = render(
+
+
+
+ {node}
+
+
+ ,
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLDivElement,
+ }));
+});
diff --git a/packages/mui-base/src/Accordion/Panel/AccordionPanel.tsx b/packages/mui-base/src/Accordion/Panel/AccordionPanel.tsx
new file mode 100644
index 0000000000..87f45f2803
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Panel/AccordionPanel.tsx
@@ -0,0 +1,88 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useCollapsibleContext } from '../../Collapsible/Root/CollapsibleContext';
+import { useCollapsibleContent } from '../../Collapsible/Content/useCollapsibleContent';
+import { useAccordionRootContext } from '../Root/AccordionRootContext';
+import type { AccordionSection } from '../Section/AccordionSection';
+import { useAccordionSectionContext } from '../Section/AccordionSectionContext';
+import { accordionStyleHookMapping } from '../Section/styleHooks';
+
+const AccordionPanel = React.forwardRef(function AccordionPanel(
+ props: AccordionPanel.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { className, htmlHidden: htmlHiddenProp, render, ...otherProps } = props;
+
+ const { animated, mounted, open, contentId, setContentId, setMounted, setOpen } =
+ useCollapsibleContext();
+
+ const { htmlHidden } = useAccordionRootContext();
+
+ const { getRootProps, height, width } = useCollapsibleContent({
+ animated,
+ htmlHidden: htmlHiddenProp || htmlHidden,
+ id: contentId,
+ mounted,
+ open,
+ ref: forwardedRef,
+ setContentId,
+ setMounted,
+ setOpen,
+ });
+
+ const { ownerState, triggerId } = useAccordionSectionContext();
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ ownerState,
+ className,
+ extraProps: {
+ ...otherProps,
+ 'aria-labelledby': triggerId,
+ role: 'region',
+ style: {
+ '--accordion-content-height': height ? `${height}px` : undefined,
+ '--accordion-content-width': width ? `${width}px` : undefined,
+ },
+ },
+ customStyleHookMapping: accordionStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+AccordionPanel.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * The hidden state when closed
+ * @default 'hidden'
+ */
+ htmlHidden: PropTypes.oneOf(['hidden', 'until-found']),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { AccordionPanel };
+
+export namespace AccordionPanel {
+ export interface Props
+ extends BaseUIComponentProps<'div', AccordionSection.OwnerState>,
+ Pick {}
+}
diff --git a/packages/mui-base/src/Accordion/Root/AccordionRoot.test.tsx b/packages/mui-base/src/Accordion/Root/AccordionRoot.test.tsx
new file mode 100644
index 0000000000..6597bf2e10
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Root/AccordionRoot.test.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react';
+// import { expect } from 'chai';
+// import { spy } from 'sinon';
+import { createRenderer /* , act */ } from '@mui/internal-test-utils';
+import * as Accordion from '@base_ui/react/Accordion';
+import { describeConformance } from '../../../test/describeConformance';
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ inheritComponent: 'div',
+ render,
+ refInstanceof: window.HTMLDivElement,
+ }));
+});
diff --git a/packages/mui-base/src/Accordion/Root/AccordionRoot.tsx b/packages/mui-base/src/Accordion/Root/AccordionRoot.tsx
new file mode 100644
index 0000000000..d3116a3602
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Root/AccordionRoot.tsx
@@ -0,0 +1,159 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import type { useCollapsibleContent } from '../../Collapsible/Content/useCollapsibleContent';
+import { CompositeList } from '../../Composite/List/CompositeList';
+import { useAccordionRoot } from './useAccordionRoot';
+import { AccordionRootContext } from './AccordionRootContext';
+
+const AccordionRoot = React.forwardRef(function AccordionRoot(
+ props: AccordionRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ animated,
+ className,
+ direction,
+ disabled = false,
+ htmlHidden,
+ onOpenChange,
+ openMultiple = true,
+ orientation,
+ value,
+ defaultValue,
+ render,
+ ...otherProps
+ } = props;
+
+ const { getRootProps, ...accordion } = useAccordionRoot({
+ animated,
+ direction,
+ disabled,
+ defaultValue,
+ orientation,
+ onOpenChange,
+ openMultiple,
+ value,
+ });
+
+ const ownerState: AccordionRoot.OwnerState = React.useMemo(
+ () => ({
+ value: accordion.value,
+ disabled: accordion.disabled,
+ orientation: accordion.orientation,
+ // transitionStatus: accordion.transitionStatus,
+ }),
+ [accordion.value, accordion.disabled, accordion.orientation],
+ );
+
+ const contextValue: AccordionRoot.Context = React.useMemo(
+ () => ({
+ ...accordion,
+ htmlHidden,
+ ownerState,
+ }),
+ [accordion, htmlHidden, ownerState],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ className,
+ ownerState,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: {
+ disabled: (isDisabled) => {
+ if (isDisabled) {
+ return { 'data-disabled': '' };
+ }
+ return null;
+ },
+ value: () => null,
+ },
+ });
+
+ return (
+
+ {renderElement()}
+
+ );
+});
+
+AccordionRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * If `true`, the component supports CSS/JS-based animations and transitions.
+ * @default true
+ */
+ animated: PropTypes.bool,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * The default value representing the currently open `Accordion.Section`
+ * This is the uncontrolled counterpart of `value`.
+ * @default 0
+ */
+ defaultValue: PropTypes.arrayOf(
+ PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ ),
+ /**
+ * @default 'ltr'
+ */
+ direction: PropTypes.oneOf(['ltr', 'rtl']),
+ /**
+ * If `true`, the component is disabled.
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ htmlHidden: PropTypes.oneOf(['hidden', 'until-found']),
+ /**
+ * Callback fired when an Accordion section is opened or closed.
+ * The value representing the involved section is provided as an argument.
+ */
+ onOpenChange: PropTypes.func,
+ /**
+ * @default 'vertical'
+ */
+ orientation: PropTypes.oneOf(['horizontal', 'vertical']),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * The value of the currently open `Accordion.Section`
+ * This is the controlled counterpart of `defaultValue`.
+ */
+ value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired),
+} as any;
+
+export { AccordionRoot };
+
+export namespace AccordionRoot {
+ export interface Context extends Omit {
+ ownerState: OwnerState;
+ htmlHidden?: useCollapsibleContent.HtmlHiddenType;
+ }
+
+ export interface OwnerState {
+ value: useAccordionRoot.Value;
+ disabled: boolean;
+ orientation: useAccordionRoot.Orientation;
+ }
+
+ export interface Props
+ extends useAccordionRoot.Parameters,
+ BaseUIComponentProps {
+ htmlHidden?: useCollapsibleContent.HtmlHiddenType;
+ }
+}
diff --git a/packages/mui-base/src/Accordion/Root/AccordionRootContext.tsx b/packages/mui-base/src/Accordion/Root/AccordionRootContext.tsx
new file mode 100644
index 0000000000..0f862f8dd6
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Root/AccordionRootContext.tsx
@@ -0,0 +1,22 @@
+'use client';
+import * as React from 'react';
+import type { AccordionRoot } from './AccordionRoot';
+
+/**
+ * @ignore - internal component.
+ */
+export const AccordionRootContext = React.createContext(
+ undefined,
+);
+
+if (process.env.NODE_ENV !== 'production') {
+ AccordionRootContext.displayName = 'AccordionRootContext';
+}
+
+export function useAccordionRootContext() {
+ const context = React.useContext(AccordionRootContext);
+ if (context === undefined) {
+ throw new Error('useAccordionRootContext must be used inside a Accordion component');
+ }
+ return context;
+}
diff --git a/packages/mui-base/src/Accordion/Root/styleHooks.ts b/packages/mui-base/src/Accordion/Root/styleHooks.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts b/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts
new file mode 100644
index 0000000000..49d94dd762
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts
@@ -0,0 +1,259 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { NOOP } from '../../utils/noop';
+import { useControlled } from '../../utils/useControlled';
+import { ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT } from '../../Composite/composite';
+
+const SUPPORTED_KEYS = [ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT, 'Home', 'End'];
+
+function isDisabled(element: HTMLElement | null) {
+ return (
+ element === null ||
+ element.hasAttribute('disabled') ||
+ element.getAttribute('data-disabled') === 'true'
+ );
+}
+/**
+ *
+ * Demos:
+ *
+ * - [Accordion](https://mui.com/base-ui/react-accordion/#hook)
+ *
+ * API:
+ *
+ * - [useAccordionRoot API](https://mui.com/base-ui/react-accordion/hooks-api/#use-accordion-root)
+ */
+export function useAccordionRoot(
+ parameters: useAccordionRoot.Parameters,
+): useAccordionRoot.ReturnValue {
+ const {
+ animated = true,
+ disabled = false,
+ direction = 'ltr',
+ onOpenChange = NOOP,
+ orientation = 'vertical',
+ openMultiple = true,
+ value: valueParam,
+ defaultValue,
+ } = parameters;
+
+ const accordionSectionRefs = React.useRef<(HTMLElement | null)[]>([]);
+
+ const [value, setValue] = useControlled({
+ controlled: valueParam,
+ default: valueParam ?? defaultValue ?? [],
+ name: 'Accordion',
+ state: 'value',
+ });
+
+ const handleOpenChange = React.useCallback(
+ (newValue: number | string, nextOpen: boolean) => {
+ // console.group('useAccordionRoot handleOpenChange');
+ // console.log('newValue', newValue, 'nextOpen', nextOpen, 'openValues', value);
+ if (!openMultiple) {
+ setValue([newValue]);
+ onOpenChange([newValue]);
+ } else if (nextOpen) {
+ const nextOpenValues = value.slice();
+ nextOpenValues.push(newValue);
+ // console.log('nextOpenValues', nextOpenValues);
+ setValue(nextOpenValues);
+ onOpenChange(nextOpenValues);
+ } else {
+ const nextOpenValues = value.filter((v) => v !== newValue);
+ // console.log('nextOpenValues', nextOpenValues);
+ setValue(nextOpenValues);
+ onOpenChange(nextOpenValues);
+ }
+ // console.groupEnd();
+ },
+ [onOpenChange, openMultiple, setValue, value],
+ );
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) => {
+ const isRtl = direction === 'rtl';
+ const isHorizontal = orientation === 'horizontal';
+ return mergeReactProps(externalProps, {
+ dir: direction,
+ role: 'region',
+ onKeyDown(event: React.KeyboardEvent) {
+ if (!SUPPORTED_KEYS.includes(event.key)) {
+ return;
+ }
+
+ // console.group('onKeyDown');
+ const { current: accordionSectionElements } = accordionSectionRefs;
+
+ // TODO: memo this outside
+ const triggers: HTMLButtonElement[] = [];
+
+ for (let i = 0; i < accordionSectionElements.length; i += 1) {
+ const section = accordionSectionElements[i];
+ if (!isDisabled(section)) {
+ const trigger = section?.querySelector('[type="button"]') as HTMLButtonElement;
+ if (!isDisabled(trigger)) {
+ triggers.push(trigger);
+ }
+ }
+ }
+
+ const numOfEnabledTriggers = triggers.length;
+ const lastIndex = numOfEnabledTriggers - 1;
+
+ let nextIndex = -1;
+
+ const thisIndex = triggers.indexOf(event.target as HTMLButtonElement);
+
+ function toNext() {
+ nextIndex = Math.min(thisIndex + 1, lastIndex);
+ }
+
+ function toPrev() {
+ nextIndex = thisIndex - 1;
+ }
+
+ switch (event.key) {
+ case ARROW_DOWN:
+ if (!isHorizontal) {
+ toNext();
+ }
+ break;
+ case ARROW_UP:
+ if (!isHorizontal) {
+ toPrev();
+ }
+ break;
+ case ARROW_RIGHT:
+ if (isHorizontal) {
+ if (isRtl) {
+ toPrev();
+ } else {
+ toNext();
+ }
+ }
+ break;
+ case ARROW_LEFT:
+ if (isHorizontal) {
+ if (isRtl) {
+ toNext();
+ } else {
+ toPrev();
+ }
+ }
+ break;
+ case 'Home':
+ nextIndex = 0;
+ break;
+ case 'End':
+ nextIndex = lastIndex;
+ break;
+ default:
+ break;
+ }
+
+ if (nextIndex > -1) {
+ // console.log('focus nextIndex', nextIndex);
+ triggers[nextIndex].focus();
+ }
+ // console.groupEnd();
+ },
+ });
+ },
+ [direction, orientation],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ accordionSectionRefs,
+ animated,
+ direction,
+ disabled,
+ handleOpenChange,
+ orientation,
+ value,
+ }),
+ [
+ getRootProps,
+ accordionSectionRefs,
+ animated,
+ direction,
+ disabled,
+ handleOpenChange,
+ orientation,
+ value,
+ ],
+ );
+}
+
+export namespace useAccordionRoot {
+ export type Value = readonly (string | number)[];
+
+ export type Direction = 'ltr' | 'rtl';
+
+ export type Orientation = 'horizontal' | 'vertical';
+
+ export interface Parameters {
+ /**
+ * The value of the currently open `Accordion.Section`
+ * This is the controlled counterpart of `defaultValue`.
+ */
+ value?: Value;
+ /**
+ * The default value representing the currently open `Accordion.Section`
+ * This is the uncontrolled counterpart of `value`.
+ * @default 0
+ */
+ defaultValue?: Value;
+ /**
+ * If `true`, the component supports CSS/JS-based animations and transitions.
+ * @default true
+ */
+ animated?: boolean;
+ /**
+ * If `true`, the component is disabled.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * @default 'ltr'
+ */
+ direction?: Direction;
+ /**
+ * Callback fired when an Accordion section is opened or closed.
+ * The value representing the involved section is provided as an argument.
+ */
+ onOpenChange?: (value: Value) => void;
+ /**
+ * Whether multiple Accordion sections can be opened at the same time
+ * @default true
+ */
+ openMultiple?: boolean;
+ /**
+ * @default 'vertical'
+ */
+ orientation?: Orientation;
+ }
+
+ export interface ReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'div'>,
+ ) => React.ComponentPropsWithRef<'div'>;
+ accordionSectionRefs: React.MutableRefObject<(HTMLElement | null)[]>;
+ animated: boolean;
+ direction: Direction;
+ /**
+ * The disabled state of the Accordion
+ */
+ disabled: boolean;
+ handleOpenChange: (value: number | string, nextOpen: boolean) => void;
+ orientation: Orientation;
+ /**
+ * The open state of the Accordion represented by an array of the values
+ * of all open ``s
+ */
+ value: Value;
+ }
+}
diff --git a/packages/mui-base/src/Accordion/Section/AccordionSection.test.tsx b/packages/mui-base/src/Accordion/Section/AccordionSection.test.tsx
new file mode 100644
index 0000000000..355a168393
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Section/AccordionSection.test.tsx
@@ -0,0 +1,41 @@
+import * as React from 'react';
+// import { expect } from 'chai';
+// import { spy } from 'sinon';
+import { createRenderer /* , act */ } from '@mui/internal-test-utils';
+import * as Accordion from '@base_ui/react/Accordion';
+import { describeConformance } from '../../../test/describeConformance';
+
+const { AccordionRootContext } = Accordion;
+
+const accordionRootContextValue: Accordion.Root.Context = {
+ accordionSectionRefs: { current: [] },
+ animated: false,
+ direction: 'ltr',
+ disabled: false,
+ handleOpenChange() {},
+ orientation: 'vertical',
+ ownerState: {
+ value: [0],
+ disabled: false,
+ orientation: 'vertical',
+ },
+ value: [0],
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ inheritComponent: 'div',
+ render: (node) => {
+ const { container, ...other } = render(
+
+ {node}
+ ,
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLDivElement,
+ }));
+});
diff --git a/packages/mui-base/src/Accordion/Section/AccordionSection.tsx b/packages/mui-base/src/Accordion/Section/AccordionSection.tsx
new file mode 100644
index 0000000000..9f0272fcf2
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Section/AccordionSection.tsx
@@ -0,0 +1,183 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useForkRef } from '../../utils/useForkRef';
+import { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useId } from '../../utils/useId';
+import type { TransitionStatus } from '../../utils/useTransitionStatus';
+import { useCollapsibleRoot } from '../../Collapsible/Root/useCollapsibleRoot';
+import type { CollapsibleRoot } from '../../Collapsible/Root/CollapsibleRoot';
+import { CollapsibleContext } from '../../Collapsible/Root/CollapsibleContext';
+import { useCompositeListItem } from '../../Composite/List/useCompositeListItem';
+import type { AccordionRoot } from '../Root/AccordionRoot';
+import { useAccordionRootContext } from '../Root/AccordionRootContext';
+import { AccordionSectionContext } from './AccordionSectionContext';
+import { accordionStyleHookMapping } from './styleHooks';
+
+const AccordionSection = React.forwardRef(function AccordionSection(
+ props: AccordionSection.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ className,
+ disabled: disabledProp,
+ onOpenChange: onOpenChangeProp,
+ render,
+ value: valueProp,
+ ...otherProps
+ } = props;
+
+ const sectionRef = React.useRef(null);
+ const { ref: listItemRef, index } = useCompositeListItem();
+ const mergedRef = useForkRef(forwardedRef, listItemRef, sectionRef);
+
+ const {
+ animated,
+ disabled: contextDisabled,
+ handleOpenChange,
+ ownerState: rootOwnerState,
+ value: openValues,
+ } = useAccordionRootContext();
+
+ const value = valueProp ?? index;
+
+ const disabled = disabledProp || contextDisabled;
+
+ const isOpen = React.useMemo(() => {
+ if (!openValues) {
+ return false;
+ }
+
+ for (let i = 0; i < openValues.length; i += 1) {
+ if (openValues[i] === value) {
+ return true;
+ }
+ }
+
+ return false;
+ }, [openValues, value]);
+
+ const onOpenChange = useEventCallback((nextOpen: boolean) => {
+ handleOpenChange(value, nextOpen);
+ if (onOpenChangeProp) {
+ onOpenChangeProp(nextOpen);
+ }
+ });
+
+ const collapsible = useCollapsibleRoot({
+ animated,
+ open: isOpen,
+ onOpenChange,
+ disabled,
+ });
+
+ const collapsibleOwnerState: CollapsibleRoot.OwnerState = React.useMemo(
+ () => ({
+ open: collapsible.open,
+ disabled: collapsible.disabled,
+ transitionStatus: collapsible.transitionStatus,
+ }),
+ [collapsible.open, collapsible.disabled, collapsible.transitionStatus],
+ );
+
+ const collapsibleContext: CollapsibleRoot.Context = React.useMemo(
+ () => ({
+ ...collapsible,
+ ownerState: collapsibleOwnerState,
+ }),
+ [collapsible, collapsibleOwnerState],
+ );
+
+ const ownerState: AccordionSection.OwnerState = React.useMemo(
+ () => ({
+ ...rootOwnerState,
+ index,
+ disabled,
+ open: isOpen,
+ transitionStatus: collapsible.transitionStatus,
+ }),
+ [collapsible.transitionStatus, disabled, index, isOpen, rootOwnerState],
+ );
+
+ const triggerId = useId();
+
+ const accordionSectionContext: AccordionSection.Context = React.useMemo(
+ () => ({
+ open: isOpen,
+ triggerId,
+ ownerState,
+ }),
+ [isOpen, ownerState, triggerId],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ render: render ?? 'div',
+ className,
+ ownerState,
+ ref: mergedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: accordionStyleHookMapping,
+ });
+
+ return (
+
+
+ {renderElement()}
+
+
+ );
+});
+
+AccordionSection.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * If `true`, the component is disabled.
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * Callback fired when the Collapsible is opened or closed.
+ */
+ onOpenChange: PropTypes.func,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * @ignore
+ */
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+} as any;
+
+export { AccordionSection };
+
+export namespace AccordionSection {
+ export type Value = number | string;
+
+ export interface Context {
+ open: boolean;
+ triggerId?: string;
+ ownerState: OwnerState;
+ }
+
+ export interface OwnerState extends AccordionRoot.OwnerState {
+ index: number;
+ open: boolean;
+ transitionStatus: TransitionStatus;
+ }
+
+ export interface Props
+ extends BaseUIComponentProps,
+ Pick {
+ value?: Value;
+ }
+}
diff --git a/packages/mui-base/src/Accordion/Section/AccordionSectionContext.tsx b/packages/mui-base/src/Accordion/Section/AccordionSectionContext.tsx
new file mode 100644
index 0000000000..291a16a06a
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Section/AccordionSectionContext.tsx
@@ -0,0 +1,24 @@
+'use client';
+import * as React from 'react';
+import type { AccordionSection } from './AccordionSection';
+
+/**
+ * @ignore - internal component.
+ */
+export const AccordionSectionContext = React.createContext(
+ undefined,
+);
+
+if (process.env.NODE_ENV !== 'production') {
+ AccordionSectionContext.displayName = 'AccordionSectionContext';
+}
+
+export function useAccordionSectionContext() {
+ const context = React.useContext(AccordionSectionContext);
+ if (context === undefined) {
+ throw new Error(
+ 'useAccordionSectionContext must be used inside the component',
+ );
+ }
+ return context;
+}
diff --git a/packages/mui-base/src/Accordion/Section/styleHooks.ts b/packages/mui-base/src/Accordion/Section/styleHooks.ts
new file mode 100644
index 0000000000..cb56d531b6
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Section/styleHooks.ts
@@ -0,0 +1,27 @@
+import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
+import type { AccordionSection } from './AccordionSection';
+
+export const accordionStyleHookMapping: CustomStyleHookMapping = {
+ disabled: (value) => {
+ if (value) {
+ return { 'data-disabled': '' };
+ }
+ return null;
+ },
+ index: (value) => {
+ return Number.isInteger(value) ? { 'data-index': String(value) } : null;
+ },
+ open: (value) => {
+ return value ? { 'data-state': 'open' } : { 'data-state': 'closed' };
+ },
+ transitionStatus: (value) => {
+ if (value === 'entering') {
+ return { 'data-entering': '' } as Record;
+ }
+ if (value === 'exiting') {
+ return { 'data-exiting': '' };
+ }
+ return null;
+ },
+ value: () => null,
+};
diff --git a/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.test.tsx b/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.test.tsx
new file mode 100644
index 0000000000..ca58f13eff
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.test.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+// import { expect } from 'chai';
+// import { spy } from 'sinon';
+import { createRenderer /* , act */ } from '@mui/internal-test-utils';
+import * as Accordion from '@base_ui/react/Accordion';
+import * as Collapsible from '@base_ui/react/Collapsible';
+import { describeConformance } from '../../../test/describeConformance';
+
+const { AccordionRootContext, AccordionSectionContext } = Accordion;
+
+const { CollapsibleContext } = Collapsible;
+
+const accordionRootContextValue: Accordion.Root.Context = {
+ accordionSectionRefs: { current: [] },
+ animated: false,
+ direction: 'ltr',
+ disabled: false,
+ handleOpenChange() {},
+ orientation: 'vertical',
+ ownerState: {
+ value: [0],
+ disabled: false,
+ orientation: 'vertical',
+ },
+ value: [0],
+};
+
+const accordionSectionContextValue: Accordion.Section.Context = {
+ open: true,
+ ownerState: {
+ value: [0],
+ disabled: false,
+ index: 0,
+ open: true,
+ orientation: 'vertical',
+ transitionStatus: undefined,
+ },
+};
+
+const collapsibleContextValue: Collapsible.Root.Context = {
+ animated: false,
+ contentId: ':content:',
+ disabled: false,
+ mounted: true,
+ open: true,
+ setContentId() {},
+ setMounted() {},
+ setOpen() {},
+ transitionStatus: undefined,
+ ownerState: {
+ open: true,
+ disabled: false,
+ transitionStatus: undefined,
+ },
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ inheritComponent: 'button',
+ render: (node) => {
+ const { container, ...other } = render(
+
+
+
+ {node}
+
+
+ ,
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLButtonElement,
+ }));
+});
diff --git a/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.tsx b/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.tsx
new file mode 100644
index 0000000000..ec1b6ec9a5
--- /dev/null
+++ b/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.tsx
@@ -0,0 +1,69 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { BaseUIComponentProps } from '../../utils/types';
+import { useCollapsibleContext } from '../../Collapsible/Root/CollapsibleContext';
+import { useCollapsibleTrigger } from '../../Collapsible/Trigger/useCollapsibleTrigger';
+import type { AccordionSection } from '../Section/AccordionSection';
+import { useAccordionSectionContext } from '../Section/AccordionSectionContext';
+import { accordionStyleHookMapping } from '../Section/styleHooks';
+
+const AccordionTrigger = React.forwardRef(function AccordionTrigger(
+ props: AccordionTrigger.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { disabled: disabledProp, className, render, ...otherProps } = props;
+
+ const { contentId, disabled: contextDisabled, open, setOpen } = useCollapsibleContext();
+
+ const { getRootProps } = useCollapsibleTrigger({
+ contentId,
+ open,
+ setOpen,
+ disabled: disabledProp || contextDisabled,
+ });
+
+ const { ownerState, triggerId } = useAccordionSectionContext();
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'button',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: { ...otherProps, id: triggerId },
+ customStyleHookMapping: accordionStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+AccordionTrigger.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * @ignore
+ */
+ disabled: PropTypes.bool,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { AccordionTrigger };
+
+namespace AccordionTrigger {
+ export interface Props extends BaseUIComponentProps<'button', AccordionSection.OwnerState> {}
+}
diff --git a/packages/mui-base/src/Accordion/index.barrel.ts b/packages/mui-base/src/Accordion/index.barrel.ts
new file mode 100644
index 0000000000..0d057e0615
--- /dev/null
+++ b/packages/mui-base/src/Accordion/index.barrel.ts
@@ -0,0 +1,5 @@
+export * from './Root/AccordionRoot';
+export * from './Section/AccordionSection';
+export * from './Heading/AccordionHeading';
+export * from './Trigger/AccordionTrigger';
+export * from './Panel/AccordionPanel';
diff --git a/packages/mui-base/src/Accordion/index.ts b/packages/mui-base/src/Accordion/index.ts
new file mode 100644
index 0000000000..7a9efb6c15
--- /dev/null
+++ b/packages/mui-base/src/Accordion/index.ts
@@ -0,0 +1,13 @@
+export { AccordionRoot as Root } from './Root/AccordionRoot';
+export { useAccordionRoot } from './Root/useAccordionRoot';
+export { AccordionRootContext, useAccordionRootContext } from './Root/AccordionRootContext';
+
+export { AccordionSection as Section } from './Section/AccordionSection';
+export {
+ AccordionSectionContext,
+ useAccordionSectionContext,
+} from './Section/AccordionSectionContext';
+
+export { AccordionHeading as Heading } from './Heading/AccordionHeading';
+export { AccordionTrigger as Trigger } from './Trigger/AccordionTrigger';
+export { AccordionPanel as Panel } from './Panel/AccordionPanel';
diff --git a/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts b/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts
index c3de348a45..c797965b3c 100644
--- a/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts
+++ b/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts
@@ -283,6 +283,8 @@ export function useCollapsibleContent(
}
export namespace useCollapsibleContent {
+ export type HtmlHiddenType = 'hidden' | 'until-found';
+
export interface Parameters {
/**
* If `true`, the component supports CSS/JS-based animations and transitions.
@@ -293,7 +295,7 @@ export namespace useCollapsibleContent {
* The hidden state when closed
* @default 'hidden'
*/
- htmlHidden?: 'hidden' | 'until-found';
+ htmlHidden?: HtmlHiddenType;
id?: React.HTMLAttributes['id'];
mounted: boolean;
/**
diff --git a/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts b/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts
index 112c48cdc4..12346f5b3a 100644
--- a/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts
+++ b/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts
@@ -14,7 +14,7 @@ import { mergeReactProps } from '../../utils/mergeReactProps';
export function useCollapsibleTrigger(
parameters: useCollapsibleTrigger.Parameters,
): useCollapsibleTrigger.ReturnValue {
- const { contentId, open, setOpen } = parameters;
+ const { contentId, disabled, open, setOpen } = parameters;
const getRootProps: useCollapsibleTrigger.ReturnValue['getRootProps'] = React.useCallback(
(externalProps = {}) =>
@@ -22,11 +22,12 @@ export function useCollapsibleTrigger(
type: 'button',
'aria-controls': contentId,
'aria-expanded': open,
+ disabled,
onClick() {
setOpen(!open);
},
}),
- [contentId, open, setOpen],
+ [contentId, disabled, open, setOpen],
);
return {
@@ -40,6 +41,7 @@ export namespace useCollapsibleTrigger {
* The id of the element controlled by the Trigger
*/
contentId: React.HTMLAttributes['id'];
+ disabled?: boolean;
/**
* The open state of the Collapsible
*/
diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts
index 7f29893f01..24c3e01687 100644
--- a/packages/mui-base/src/index.ts
+++ b/packages/mui-base/src/index.ts
@@ -1,3 +1,4 @@
+export * from './Accordion/index.barrel';
export * from './AlertDialog/index.barrel';
export * from './Checkbox/index.barrel';
export * from './Dialog/index.barrel';
diff --git a/packages/mui-base/src/utils/defaultRenderFunctions.tsx b/packages/mui-base/src/utils/defaultRenderFunctions.tsx
index 4fd8e3ef4a..5bcd3fbc93 100644
--- a/packages/mui-base/src/utils/defaultRenderFunctions.tsx
+++ b/packages/mui-base/src/utils/defaultRenderFunctions.tsx
@@ -11,6 +11,10 @@ export const defaultRenderFunctions = {
// eslint-disable-next-line jsx-a11y/heading-has-content
return ;
},
+ h3: (props: React.ComponentPropsWithRef<'h3'>) => {
+ // eslint-disable-next-line jsx-a11y/heading-has-content
+ return ;
+ },
output: (props: React.ComponentPropsWithRef<'output'>) => {
return ;
},