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 ; },