diff --git a/docs/data/api/accordion-header.json b/docs/data/api/accordion-header.json new file mode 100644 index 0000000000..b1399816f8 --- /dev/null +++ b/docs/data/api/accordion-header.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "AccordionHeader", + "imports": [ + "import { Accordion } from '@base_ui/react/Accordion';\nconst AccordionHeader = Accordion.Header;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "AccordionHeader", + "forwardsRefTo": "HTMLHeadingElement", + "filename": "/packages/mui-base/src/Accordion/Header/AccordionHeader.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/accordion-heading.json b/docs/data/api/accordion-heading.json new file mode 100644 index 0000000000..72627dc089 --- /dev/null +++ b/docs/data/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 { 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/data/api/accordion-item.json b/docs/data/api/accordion-item.json new file mode 100644 index 0000000000..c708653f6b --- /dev/null +++ b/docs/data/api/accordion-item.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": "AccordionItem", + "imports": [ + "import { Accordion } from '@base_ui/react/Accordion';\nconst AccordionItem = Accordion.Item;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "AccordionItem", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Accordion/Item/AccordionItem.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/accordion-panel.json b/docs/data/api/accordion-panel.json new file mode 100644 index 0000000000..dd8247c8bc --- /dev/null +++ b/docs/data/api/accordion-panel.json @@ -0,0 +1,20 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "hiddenUntilFound": { "type": { "name": "bool" }, "default": "false" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "AccordionPanel", + "imports": [ + "import { 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/data/api/accordion-root.json b/docs/data/api/accordion-root.json new file mode 100644 index 0000000000..9747dc52f0 --- /dev/null +++ b/docs/data/api/accordion-root.json @@ -0,0 +1,31 @@ +{ + "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" }, + "loop": { "type": { "name": "bool" }, "default": "true" }, + "onValueChange": { "type": { "name": "func" } }, + "openMultiple": { "type": { "name": "bool" }, "default": "true" }, + "render": { "type": { "name": "union", "description": "element
| func" } }, + "value": { + "type": { "name": "arrayOf", "description": "Array<number
| string>" } + } + }, + "name": "AccordionRoot", + "imports": [ + "import { 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/data/api/accordion-section.json b/docs/data/api/accordion-section.json new file mode 100644 index 0000000000..8736770302 --- /dev/null +++ b/docs/data/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 { 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/data/api/accordion-trigger.json b/docs/data/api/accordion-trigger.json new file mode 100644 index 0000000000..0a1dda62cb --- /dev/null +++ b/docs/data/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 { 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/data/api/collapsible-content.json b/docs/data/api/collapsible-content.json index e44ca41c5f..424829be72 100644 --- a/docs/data/api/collapsible-content.json +++ b/docs/data/api/collapsible-content.json @@ -1,10 +1,7 @@ { "props": { "className": { "type": { "name": "union", "description": "func
| string" } }, - "htmlHidden": { - "type": { "name": "enum", "description": "'hidden'
| 'until-found'" }, - "default": "'hidden'" - }, + "hiddenUntilFound": { "type": { "name": "bool" }, "default": "false" }, "render": { "type": { "name": "union", "description": "element
| func" } } }, "name": "CollapsibleContent", diff --git a/docs/data/api/collapsible-panel.json b/docs/data/api/collapsible-panel.json new file mode 100644 index 0000000000..35c82f11f1 --- /dev/null +++ b/docs/data/api/collapsible-panel.json @@ -0,0 +1,20 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "hiddenUntilFound": { "type": { "name": "bool" }, "default": "false" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "CollapsiblePanel", + "imports": [ + "import { Collapsible } from '@base_ui/react/Collapsible';\nconst CollapsiblePanel = Collapsible.Panel;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "CollapsiblePanel", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Collapsible/Panel/CollapsiblePanel.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/use-accordion-root.json b/docs/data/api/use-accordion-root.json new file mode 100644 index 0000000000..53bd7e2aef --- /dev/null +++ b/docs/data/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/data/components/accordion/UnstyledAccordionIntroduction.js b/docs/data/components/accordion/UnstyledAccordionIntroduction.js new file mode 100644 index 0000000000..378b8c0bad --- /dev/null +++ b/docs/data/components/accordion/UnstyledAccordionIntroduction.js @@ -0,0 +1,61 @@ +'use client'; +import * as React from 'react'; +import { Accordion } from '@base_ui/react/Accordion'; +import classes from './styles.module.css'; + +export default function UnstyledAccordionIntroduction() { + return ( +
+ + + + + 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 + + + +
+ ); +} + +function ExpandMoreIcon(props) { + return ( + + + + ); +} diff --git a/docs/data/components/accordion/UnstyledAccordionIntroduction.tsx b/docs/data/components/accordion/UnstyledAccordionIntroduction.tsx new file mode 100644 index 0000000000..09f316335f --- /dev/null +++ b/docs/data/components/accordion/UnstyledAccordionIntroduction.tsx @@ -0,0 +1,61 @@ +'use client'; +import * as React from 'react'; +import { Accordion } from '@base_ui/react/Accordion'; +import classes from './styles.module.css'; + +export default function UnstyledAccordionIntroduction() { + return ( +
+ + + + + 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 + + + +
+ ); +} + +function ExpandMoreIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/docs/data/components/accordion/accordion.mdx b/docs/data/components/accordion/accordion.mdx new file mode 100644 index 0000000000..4e00f86083 --- /dev/null +++ b/docs/data/components/accordion/accordion.mdx @@ -0,0 +1,332 @@ +--- +productId: base-ui +title: React Accordion components +components: AccordionRoot, AccordionItem, AccordionHeader, AccordionTrigger, AccordionPanel +githubLabel: 'component: accordion' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ +packageName: '@base_ui/react' +--- + +# Accordion + + + + + +## Introduction + + + +## Installation + + + +## Anatomy + +Accordions are implemented using a collection of related components: + +- `` is a top-level component that wraps the other components. +- `` is a component that wraps each section of content and it's associated `Trigger` +- `` is a button that toggles the open state of its associated `Item` +- `` is a heading (`h3` by default) that wraps the `Trigger` +- `` is the element that contains content in a `Item` + +```tsx + + + + Toggle one + + Panel one content + + + + Toggle two + + Panel two content + + +``` + +## Value + +Each `Accordion.Item` is represented by a value, which by default is its zero-based index by DOM position. +The first `Item` has an implicit `value` of `0`, the second one `Item` a `value` of `1`, and so on. + +The open state of the accordion is represented an array holding the `value`s of all open `Item`s. + +You can optionally specify a custom `value` prop on `Item`: + +```tsx + + + + Toggle one + + Panel one content + + + + Toggle two + + Panel two content + + +``` + +### Default value + +When uncontrolled, use the `defaultValue` prop to set the initial state of the accordion: + +```tsx + + + + Toggle one + + Panel one content + + + + Toggle two + + Panel two content + +; + +// with custom `value`s + + + + Toggle one + + Panel one content + + + + Toggle two + + Panel two content + +; +``` + +### Controlled + +When controlled, pass the `value` and `onValueChange` props to `Accordion.Root`: + +```tsx +const [value, setValue] = React.useState(['a']); + +return ( + + + + Toggle one + + Panel one content + + + + Toggle two + + Panel two content + + +); +``` + +## Customization + +### Only one `Item` open at a time + +By default, all accordion items can be opened at the same time. Use the `openMultiple` prop to only allow one open item at a time: + +```tsx +{/* subcomponents */} +``` + +### At least one `Item` remains open + +Use controlled mode to always keep one `Item` open: + +```tsx +const [value, setValue] = React.useState([0]); + +const handleValueChange = (newValue) => { + if (newValue.length > 0) { + setValue(newValue); + } +}; + +return ( + + {/* subcomponents */} + +); +``` + +## Horizontal + +Use the `orientation` prop to configure a horizontal accordion. In a horizontal accordion, focus will move between `Accordion.Trigger`s with the Right Arrow and Left Arrow keys, instead of Down/Up. + +```tsx +{/* subcomponents */} +``` + +## RTL + +Use the `direction` prop to configure a RTL accordion: + +```tsx +{/* subcomponents */} +``` + +When a horizontal accordion is set to `direction="rtl"`, keyboard actions are reversed accordingly - Left Arrow moves focus to the next trigger and Right Arrow moves focus to the previous trigger. + +## Improving searchability of hidden content + + + This is [not yet + supported](https://caniuse.com/mdn-html_global_attributes_hidden_until-found_value) in Safari and + Firefox as of August 2024 and will fall back to the default `hidden` behavior. + + +Content hidden by `Accordion.Panel` components can be made accessible only to a browser's find-in-page functionality with the `hiddenUntilFound` prop to improve searchability: + +```js +{/* subcomponents */} +``` + +Alternatively `hiddenUntilFound` can be passed to `Accordion.Panel`s directly to enable this for only one `Item` instead of the whole accordion. + +We recommend using [CSS animations](#css-animations) for animated accordions that use this feature. Currently there is browser bug that does not highlight the found text inside elements that have a [CSS transition](#css-transitions) applied. + +This relies on the HTML `hidden="until-found"` attribute which only has [partial browser support](https://caniuse.com/mdn-html_global_attributes_hidden_until-found_value) as of August 2024, but automatically falls back to the default `hidden` state in unsupported browsers. + +## Animations + +Accordion uses [`Collapsible`](/components/react-collapsible) internally, and can be animated in a [similar way](/components/react-collapsible#animations). + +Three states are available as data attributes to animate the `Accordion.Panel`: + +- `[data-open]` - `open` state is `true`. +- `[data-entering]` - the `hidden` attribute was just removed from the DOM and the panel element participates in page layout. The `data-entering` attribute will be removed 1 animation frame later. +- `[data-exiting]` - the panel element is in the process of being hidden from the DOM, but is still mounted. + +The component can be animate when opening or closing using either: + +- CSS animations +- CSS transitions +- JavaScript animations + +### Styling + +The `Accordion.Panel` element receives the following CSS variables about its dimensions, which can be used to style animations or transitions: + +- `--accordion-panel-height`: Specifies the height of the `Panel`. +- `--accordion-panel-width`: Specifies the width of the `Panel`. + +### CSS Animations + +CSS animations can be used with two declarations: + +```css +.AccordionPanel { + overflow: hidden; + animation: slideUp 300ms ease-in; +} + +.AccordionPanel[data-open] { + animation: slideDown 300ms ease-out; +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--accordion-panel-height); + } +} + +@keyframes slideUp { + from { + height: var(--accordion-panel-height); + } + to { + height: 0; + } +} +``` + +### CSS Transitions + +When using CSS transitions, styles for the `Panel` must be applied to three states: + +- The exiting styles, placed on the base element class +- The open styles, placed on the base element class with `[data-state="open"]` +- The entering styles, placed on the base element class with `[data-entering]` + +```css +.AccordionPanel { + overflow: hidden; + /* The final styles once closed/exited */ + height: 0; + transition: height 300ms ease-in; +} + +/* The final styles once opened/entered */ +.AccordionPanel[data-open] { + height: var(--accordion-panel-height); + transition: height 300ms ease-out; +} + +/* The initial styles when opening/entering */ +.AccordionPanel[data-entering] { + height: 0; +} +``` + +### JavaScript Animations + +When using external libraries for animation, for example `framer-motion`, be aware that `Accordion.Item`s hides content using the html `hidden` attribute in the closed state, and does not unmount from the DOM. + +```js +function App() { + const [value, setValue] = useState([0]); + return ( + + + + Toggle + + + } + > + This is the content + + + {/* more accordion items */} + + ); +} +``` diff --git a/docs/data/components/accordion/styles.module.css b/docs/data/components/accordion/styles.module.css new file mode 100644 index 0000000000..acf6b0c782 --- /dev/null +++ b/docs/data/components/accordion/styles.module.css @@ -0,0 +1,81 @@ +.demo { + width: 40rem; + margin: 1rem; +} + +.root { + --shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), + 0px 1px 3px 0px rgba(0, 0, 0, 0.12); + + font-family: system-ui, sans-serif; + box-shadow: var(--shadow); + background-color: rgba(0, 0, 0, 0.12); + border-radius: 0.3rem; +} + +.item { + position: relative; + background-color: #fff; + color: rgba(0, 0, 0, 0.87); +} + +.item:not(:first-of-type) { + margin-top: 1px; +} + +.item:first-of-type { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.item:last-of-type { + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.header { + margin: 0; + border-radius: inherit; +} + +.header:hover { + cursor: pointer; +} + +.trigger { + appearance: none; + background-color: transparent; + border: 0; + border-radius: inherit; + color: inherit; + padding: 0 1rem; + position: relative; + width: 100%; + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +.trigger:hover { + cursor: pointer; + background-color: rgba(0, 0, 0, 0.12); +} + +.trigger:focus-visible { + outline: 2px solid black; + z-index: 1; +} + +.trigger .triggerText { + font-size: 1rem; + line-height: 1.5; + margin: 12px auto 12px 0; +} + +.trigger[data-panel-open] svg { + transform: rotate(180deg); +} + +.panel { + padding: 1rem; +} diff --git a/docs/data/components/collapsible/CssAnimatedCollapsible.js b/docs/data/components/collapsible/CssAnimatedCollapsible.js index 909cf3418f..9297367dff 100644 --- a/docs/data/components/collapsible/CssAnimatedCollapsible.js +++ b/docs/data/components/collapsible/CssAnimatedCollapsible.js @@ -1,139 +1,47 @@ 'use client'; import * as React from 'react'; -import { useTheme } from '@mui/system'; +import clsx from 'clsx'; import { Collapsible } from '@base_ui/react/Collapsible'; +import animationClasses from './animations.module.css'; +import classes from './styles.module.css'; export default function CssAnimatedCollapsible() { const [open, setOpen] = React.useState(true); return ( -
- - - - - - - - Show {open ? 'less' : 'more'} - - -

This is the collapsed content

-

This is the second paragraph

-

This is a longer sentence and also the third paragraph

-
-
- -
+ + + + Show {open ? 'less' : 'more'} + + +

This is the collapsed content

+

+ You can find the Base UI repository{' '} + + here + +

+

This is a longer sentence and also the third paragraph

+
+
); } -const grey = { - 50: '#F3F6F9', - 100: '#E5EAF2', - 200: '#DAE2ED', - 300: '#C7D0DD', - 400: '#B0B8C4', - 500: '#9DA8B7', - 600: '#6B7A90', - 700: '#434D5B', - 800: '#303740', - 900: '#1C2025', -}; - -function useIsDarkMode() { - const theme = useTheme(); - return theme.palette.mode === 'dark'; -} - -export function Styles() { - const isDarkMode = useIsDarkMode(); +function ExpandMoreIcon(props) { return ( - + + + ); } diff --git a/docs/data/components/collapsible/CssAnimatedCollapsible.tsx b/docs/data/components/collapsible/CssAnimatedCollapsible.tsx index 909cf3418f..eaaad957e0 100644 --- a/docs/data/components/collapsible/CssAnimatedCollapsible.tsx +++ b/docs/data/components/collapsible/CssAnimatedCollapsible.tsx @@ -1,139 +1,47 @@ 'use client'; import * as React from 'react'; -import { useTheme } from '@mui/system'; +import clsx from 'clsx'; import { Collapsible } from '@base_ui/react/Collapsible'; +import animationClasses from './animations.module.css'; +import classes from './styles.module.css'; export default function CssAnimatedCollapsible() { const [open, setOpen] = React.useState(true); return ( -
- - - - - - - - Show {open ? 'less' : 'more'} - - -

This is the collapsed content

-

This is the second paragraph

-

This is a longer sentence and also the third paragraph

-
-
- -
+ + + + Show {open ? 'less' : 'more'} + + +

This is the collapsed content

+

+ You can find the Base UI repository{' '} + + here + +

+

This is a longer sentence and also the third paragraph

+
+
); } -const grey = { - 50: '#F3F6F9', - 100: '#E5EAF2', - 200: '#DAE2ED', - 300: '#C7D0DD', - 400: '#B0B8C4', - 500: '#9DA8B7', - 600: '#6B7A90', - 700: '#434D5B', - 800: '#303740', - 900: '#1C2025', -}; - -function useIsDarkMode() { - const theme = useTheme(); - return theme.palette.mode === 'dark'; -} - -export function Styles() { - const isDarkMode = useIsDarkMode(); +function ExpandMoreIcon(props: React.SVGProps) { return ( - + + + ); } diff --git a/docs/data/components/collapsible/CssTransitionCollapsible.js b/docs/data/components/collapsible/CssTransitionCollapsible.js index 612069408f..2a305df785 100644 --- a/docs/data/components/collapsible/CssTransitionCollapsible.js +++ b/docs/data/components/collapsible/CssTransitionCollapsible.js @@ -1,128 +1,48 @@ 'use client'; import * as React from 'react'; -import { useTheme } from '@mui/system'; +import clsx from 'clsx'; import { Collapsible } from '@base_ui/react/Collapsible'; +import transitionClasses from './transitions.module.css'; +import classes from './styles.module.css'; export default function CssTransitionCollapsible() { const [open, setOpen] = React.useState(true); return ( -
- - - - - - - - Show {open ? 'less' : 'more'} - - -

This is the collapsed content

-

This is the second paragraph

-

This is a longer sentence and also the third paragraph

-
-
- -
+ + + + Show {open ? 'less' : 'more'} + + +

This is the collapsed content.

+

+ You can find the Base UI repository{' '} + + here + + . +

+

This is a longer sentence and also the third paragraph.

+
+
); } -const grey = { - 50: '#F3F6F9', - 100: '#E5EAF2', - 200: '#DAE2ED', - 300: '#C7D0DD', - 400: '#B0B8C4', - 500: '#9DA8B7', - 600: '#6B7A90', - 700: '#434D5B', - 800: '#303740', - 900: '#1C2025', -}; - -function useIsDarkMode() { - const theme = useTheme(); - return theme.palette.mode === 'dark'; -} - -export function Styles() { - const isDarkMode = useIsDarkMode(); +function ExpandMoreIcon(props) { return ( - + + + ); } diff --git a/docs/data/components/collapsible/CssTransitionCollapsible.tsx b/docs/data/components/collapsible/CssTransitionCollapsible.tsx index 612069408f..7961ef6fc6 100644 --- a/docs/data/components/collapsible/CssTransitionCollapsible.tsx +++ b/docs/data/components/collapsible/CssTransitionCollapsible.tsx @@ -1,128 +1,48 @@ 'use client'; import * as React from 'react'; -import { useTheme } from '@mui/system'; +import clsx from 'clsx'; import { Collapsible } from '@base_ui/react/Collapsible'; +import transitionClasses from './transitions.module.css'; +import classes from './styles.module.css'; export default function CssTransitionCollapsible() { const [open, setOpen] = React.useState(true); return ( -
- - - - - - - - Show {open ? 'less' : 'more'} - - -

This is the collapsed content

-

This is the second paragraph

-

This is a longer sentence and also the third paragraph

-
-
- -
+ + + + Show {open ? 'less' : 'more'} + + +

This is the collapsed content.

+

+ You can find the Base UI repository{' '} + + here + + . +

+

This is a longer sentence and also the third paragraph.

+
+
); } -const grey = { - 50: '#F3F6F9', - 100: '#E5EAF2', - 200: '#DAE2ED', - 300: '#C7D0DD', - 400: '#B0B8C4', - 500: '#9DA8B7', - 600: '#6B7A90', - 700: '#434D5B', - 800: '#303740', - 900: '#1C2025', -}; - -function useIsDarkMode() { - const theme = useTheme(); - return theme.palette.mode === 'dark'; -} - -export function Styles() { - const isDarkMode = useIsDarkMode(); +function ExpandMoreIcon(props: React.SVGProps) { return ( - + + + ); } diff --git a/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.js b/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.js index 2ce225655d..f880388d3c 100644 --- a/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.js +++ b/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.js @@ -1,77 +1,42 @@ 'use client'; import * as React from 'react'; -import { styled, useTheme, Box } from '@mui/system'; -import { Collapsible as BaseCollapsible } from '@base_ui/react/Collapsible'; - -const Collapsible = BaseCollapsible.Root; - -const CollapsibleTrigger = styled(BaseCollapsible.Trigger)` - display: flex; - flex-flow: row nowrap; - justify-content: center; - gap: 4px; - font-size: 16px; - - & svg { - margin-top: 1px; - } - - &[data-open] svg { - transform: rotate(180deg); - } -`; - -const CollapsibleContent = styled(BaseCollapsible.Content)``; +import { Collapsible } from '@base_ui/react/Collapsible'; +import classes from './styles.module.css'; export default function UnstyledCollapsibleIntroduction() { - // Replace this with your app logic for determining dark mode - const isDarkMode = useIsDarkMode(); const [open, setOpen] = React.useState(true); return ( - - - - - - - Show {open ? 'less' : 'more'} - - -

- This is the collapsed content. The element that shows and hides the - content has role button -

-

- When the content is visible, the element with role `button` has - `aria-expanded` set to `true` -

-

When the content area is hidden, it is set to `false`

-

- Optionally, the element with role `button` has a value specified for - `aria-controls` that refers to the element that contains all the content - that is shown or hidden -

-
-
-
+ + + + Show {open ? 'less' : 'more'} + + +

+ This is the collapsed content. The element that shows and hides the content + has role button +

+

+ When the content is visible, the element with role `button` has + `aria-expanded` set to `true` +

+

When the content panel is hidden, it is set to `false`

+
+
); } -function useIsDarkMode() { - const theme = useTheme(); - return theme.palette.mode === 'dark'; +function ExpandMoreIcon(props) { + return ( + + + + ); } diff --git a/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.tsx b/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.tsx index 2ce225655d..738789210f 100644 --- a/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.tsx +++ b/docs/data/components/collapsible/UnstyledCollapsibleIntroduction.tsx @@ -1,77 +1,42 @@ 'use client'; import * as React from 'react'; -import { styled, useTheme, Box } from '@mui/system'; -import { Collapsible as BaseCollapsible } from '@base_ui/react/Collapsible'; - -const Collapsible = BaseCollapsible.Root; - -const CollapsibleTrigger = styled(BaseCollapsible.Trigger)` - display: flex; - flex-flow: row nowrap; - justify-content: center; - gap: 4px; - font-size: 16px; - - & svg { - margin-top: 1px; - } - - &[data-open] svg { - transform: rotate(180deg); - } -`; - -const CollapsibleContent = styled(BaseCollapsible.Content)``; +import { Collapsible } from '@base_ui/react/Collapsible'; +import classes from './styles.module.css'; export default function UnstyledCollapsibleIntroduction() { - // Replace this with your app logic for determining dark mode - const isDarkMode = useIsDarkMode(); const [open, setOpen] = React.useState(true); return ( - - - - - - - Show {open ? 'less' : 'more'} - - -

- This is the collapsed content. The element that shows and hides the - content has role button -

-

- When the content is visible, the element with role `button` has - `aria-expanded` set to `true` -

-

When the content area is hidden, it is set to `false`

-

- Optionally, the element with role `button` has a value specified for - `aria-controls` that refers to the element that contains all the content - that is shown or hidden -

-
-
-
+ + + + Show {open ? 'less' : 'more'} + + +

+ This is the collapsed content. The element that shows and hides the content + has role button +

+

+ When the content is visible, the element with role `button` has + `aria-expanded` set to `true` +

+

When the content panel is hidden, it is set to `false`

+
+
); } -function useIsDarkMode() { - const theme = useTheme(); - return theme.palette.mode === 'dark'; +function ExpandMoreIcon(props: React.SVGProps) { + return ( + + + + ); } diff --git a/docs/data/components/collapsible/animations.module.css b/docs/data/components/collapsible/animations.module.css new file mode 100644 index 0000000000..229159fb3c --- /dev/null +++ b/docs/data/components/collapsible/animations.module.css @@ -0,0 +1,25 @@ +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--collapsible-panel-height); + } +} + +@keyframes slideUp { + from { + height: var(--collapsible-panel-height); + } + to { + height: 0; + } +} + +.panel[data-open] { + animation: slideDown 300ms ease-out; +} + +.panel { + animation: slideUp 300ms ease-out; +} diff --git a/docs/data/components/collapsible/collapsible.mdx b/docs/data/components/collapsible/collapsible.mdx index a1f3031534..752896f1ba 100644 --- a/docs/data/components/collapsible/collapsible.mdx +++ b/docs/data/components/collapsible/collapsible.mdx @@ -2,7 +2,7 @@ productId: base-ui title: React Collapsible components description: Collapsible is a component that shows or hides content. -components: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent +components: CollapsibleRoot, CollapsibleTrigger, CollapsiblePanel githubLabel: 'component: collapsible' waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ packageName: '@base_ui/react' @@ -23,13 +23,13 @@ packageName: '@base_ui/react' ## Anatomy - `` is a top-level component that facilitates communication between other components. It does not render to the DOM by default. -- `` is the trigger element, a ` +
+
+ + + + + Trigger 3 + + + + This is the contents of Accordion.Panel 3 + MUI + + + + + + + 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" + + + + +
+ +

Controlled, at least one section must remain open

+ + { + if (newValue.length > 0) { + setVal2(newValue); + } + }} + aria-label="Controlled Accordion, one section must remain open" + openMultiple={openMultiple} + > + + + + 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" + + + + + ); +} + +function CheckIcon(props: React.SVGProps) { + return ( + + + + ); +} + +export function ExpandMoreIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/docs/src/app/experiments/collapsible-accordion.tsx b/docs/src/app/experiments/collapsible-accordion.tsx index 017a00c391..626c8769ec 100644 --- a/docs/src/app/experiments/collapsible-accordion.tsx +++ b/docs/src/app/experiments/collapsible-accordion.tsx @@ -28,11 +28,11 @@ function AccordionSection(props: { {isOpen ? 'Close' : 'Open'} Panel {index} - +

This is the collapsed content of Panel {index}

This is the second paragraph

This is the third paragraph

-
+ ); diff --git a/docs/src/app/experiments/collapsible-cls.tsx b/docs/src/app/experiments/collapsible-cls.tsx new file mode 100644 index 0000000000..7607f5bc34 --- /dev/null +++ b/docs/src/app/experiments/collapsible-cls.tsx @@ -0,0 +1,110 @@ +'use client'; +import * as React from 'react'; +import { Collapsible } from '@base_ui/react/Collapsible'; +import classes from './collapsible.module.css'; + +function classNames(...c: Array) { + return c.filter(Boolean).join(' '); +} + +// https://github.com/mui/base-ui/issues/740 +export default function AnimatedCollapsibles() { + return ( +
+ + + + Trigger 1A (CSS Animation) + + +

This is the collapsed content

+

+ You can find the Base UI repository{' '} + + here + +

+
+
+ + + + + Trigger 1B (CSS Animation) + + +

This is the collapsed content

+

+ You can find the Base UI repository{' '} + + here + +

+
+
+ + + + + Trigger 2A (CSS Transition) + + +

This is the collapsed content

+

+ You can find the Base UI repository{' '} + + here + +

+
+
+ + + + + Trigger 2B (CSS Transition) + + +

This is the collapsed content

+

+ You can find the Base UI repository{' '} + + here + +

+
+
+
+ ); +} + +function ExpandMoreIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/docs/src/app/experiments/collapsible-framer.tsx b/docs/src/app/experiments/collapsible-framer.tsx index ee116e0801..dbfd17c567 100644 --- a/docs/src/app/experiments/collapsible-framer.tsx +++ b/docs/src/app/experiments/collapsible-framer.tsx @@ -1,34 +1,34 @@ 'use client'; import * as React from 'react'; -import { useTheme } from '@mui/system'; import { Collapsible } from '@base_ui/react/Collapsible'; import { motion } from 'framer-motion'; +import classes from './collapsible.module.css'; + +function classNames(...c: Array) { + return c.filter(Boolean).join(' '); +} export default function CollapsibleFramer() { const [open, setOpen] = React.useState(false); return ( -
+
- - - - - - + + Trigger -

demo: https://codepen.io/aardrian/pen/QWjBNQG

https://adrianroselli.com/2020/05/disclosure-widgets.html

-
+
-
); } -const grey = { - 50: '#F3F6F9', - 100: '#E5EAF2', - 200: '#DAE2ED', - 300: '#C7D0DD', - 400: '#B0B8C4', - 500: '#9DA8B7', - 600: '#6B7A90', - 700: '#434D5B', - 800: '#303740', - 900: '#1C2025', -}; - -function useIsDarkMode() { - const theme = useTheme(); - return theme.palette.mode === 'dark'; -} - -export function Styles() { - const isDarkMode = useIsDarkMode(); +function ExpandMoreIcon(props: React.SVGProps) { return ( - + + + ); } diff --git a/docs/src/app/experiments/collapsible-hidden-until-found.tsx b/docs/src/app/experiments/collapsible-hidden-until-found.tsx index b706525b9b..d42c30c7ea 100644 --- a/docs/src/app/experiments/collapsible-hidden-until-found.tsx +++ b/docs/src/app/experiments/collapsible-hidden-until-found.tsx @@ -1,13 +1,16 @@ 'use client'; import * as React from 'react'; import { Collapsible } from '@base_ui/react/Collapsible'; +import classes from './collapsible.module.css'; -const DURATION = '350ms'; +function classNames(...c: Array) { + return c.filter(Boolean).join(' '); +} export default function CollapsibleHiddenUntilFound() { return ( -
+      
         All 3 Collapsibles contain the text "May the force be with you" but
         only the content in the 2nd and 3rd Collapsible will be revealed by the
         browser's in-page search (e.g. Ctrl/Cmd-F) in{' '}
@@ -20,224 +23,69 @@ export default function CollapsibleHiddenUntilFound() {
         the opening animation will be disabled (one-off) and open instantly
         

- Collapsible #2 is animated using CSS @keyframe animation, while #3 is + Collapsible #2 is animated using CSS @keyframe animation, while #1 and #3 is animated with CSS transitions. #3 demonstrates a possible browser bug where the matching text is not properly highlighted by the browser the first time that instance is matched. It only occurs with transitions, not @keyframe animations.
-
+
- - - - - - - Toggle 1 + + + Trigger 1 - +

This is the collapsed content

May the force be with you

-
- +
- - - - - - - Toggle 2 + + + Trigger 2 -

This is the collapsed content

May the force be with you

-
- +
- - - - - - - Toggle 3 + + + Trigger 3 -

This is the collapsed content

May the force be with you

-
- +
); } -const grey = { - 50: '#F3F6F9', - 100: '#E5EAF2', - 200: '#DAE2ED', - 300: '#C7D0DD', - 400: '#B0B8C4', - 500: '#9DA8B7', - 600: '#6B7A90', - 700: '#434D5B', - 800: '#303740', - 900: '#1C2025', -}; - -export function Styles() { +function ExpandMoreIcon(props: React.SVGProps) { return ( - + + + ); } diff --git a/docs/src/app/experiments/collapsible.module.css b/docs/src/app/experiments/collapsible.module.css new file mode 100644 index 0000000000..d4e2bfd0ec --- /dev/null +++ b/docs/src/app/experiments/collapsible.module.css @@ -0,0 +1,98 @@ +.wrapper { + --width: 320px; + --duration: 300ms; + + font-family: system-ui, sans-serif; + line-height: 1.4; + display: flex; + flex-flow: column nowrap; + align-items: stretch; + width: var(--width); + margin: 2rem; +} + +.pre { + line-height: 1.5; + max-width: 75ch; + white-space: pre-wrap; + margin: 1rem 1rem 2rem; +} + +.trigger { + display: flex; + align-items: center; + padding-left: 0; +} + +.trigger:not(:first-of-type) { + margin-top: 3rem; +} + +.icon { + transform: rotate(-90deg); + transition: transform var(--duration) ease-in; +} + +.trigger[data-open] .icon { + transform: rotate(0); + transition: transform var(--duration) ease-out; +} + +.panel { + background-color: #eaeaea; + overflow: hidden; + box-sizing: border-box; + width: var(--width); + padding-left: 1rem; + padding-right: 1rem; +} + +.panel.animation[data-open] { + animation: slideDown var(--duration) ease-out; +} + +.panel.animation { + animation: slideUp var(--duration) ease-in; +} + +.panel.transition[data-open] { + height: var(--collapsible-panel-height); + transition: height var(--duration) ease-out; +} + +.panel.transition { + height: 0; + transition: height var(--duration) ease-in; +} + +.panel.transition[data-entering] { + height: 0; +} + +.panel.framer { + display: flex; + flex-direction: column; +} + +.panel p { + margin: 1.25rem auto; + overflow-wrap: break-word; +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--collapsible-panel-height); + } +} + +@keyframes slideUp { + from { + height: var(--collapsible-panel-height); + } + to { + height: 0; + } +} diff --git a/docs/src/app/experiments/collapsible.tsx b/docs/src/app/experiments/collapsible.tsx index 2b5c3925ba..80c50345cd 100644 --- a/docs/src/app/experiments/collapsible.tsx +++ b/docs/src/app/experiments/collapsible.tsx @@ -21,12 +21,12 @@ export default function CollapsibleDemo() { Trigger (CSS animation) - +

This is the collapsed content

This component is animated with CSS @keyframe animations

demo: https://codepen.io/aardrian/pen/QWjBNQG

https://adrianroselli.com/2020/05/disclosure-widgets.html

-
+
@@ -44,12 +44,12 @@ export default function CollapsibleDemo() { Trigger (CSS transition) - +

This is the collapsed content

This component is animated with CSS transitions

demo: https://codepen.io/aardrian/pen/QWjBNQG

https://adrianroselli.com/2020/05/disclosure-widgets.html

-
+
@@ -66,12 +66,12 @@ export default function CollapsibleDemo() { Trigger (root renders a span + CSS transition) - +

This is the collapsed content

This component is animated with CSS transitions

demo: https://codepen.io/aardrian/pen/QWjBNQG

https://adrianroselli.com/2020/05/disclosure-widgets.html

-
+ diff --git a/docs/src/app/experiments/slider.module.css b/docs/src/app/experiments/slider.module.css index 99aecc36a3..046590f717 100644 --- a/docs/src/app/experiments/slider.module.css +++ b/docs/src/app/experiments/slider.module.css @@ -94,6 +94,6 @@ font-weight: bold; } -.label[data-disabled='true'] { +.label[data-disabled] { color: var(--gray-600); } diff --git a/docs/src/app/experiments/tabs.tsx b/docs/src/app/experiments/tabs.tsx index fc8acc3d11..a43bf362b5 100644 --- a/docs/src/app/experiments/tabs.tsx +++ b/docs/src/app/experiments/tabs.tsx @@ -193,7 +193,7 @@ const Tab = styled(BaseTabs.Tab)` outline: none; } - &[data-disabled='true'] { + &[data-disabled] { opacity: 0.5; cursor: not-allowed; } diff --git a/docs/src/components/content/PackageManagerSnippet.module.css b/docs/src/components/content/PackageManagerSnippet.module.css index 2dc3b3fd12..9ecb333899 100644 --- a/docs/src/components/content/PackageManagerSnippet.module.css +++ b/docs/src/components/content/PackageManagerSnippet.module.css @@ -44,7 +44,7 @@ width: var(--space-9); letter-spacing: 0.18px; - &[data-selected='true'] { + &[data-selected] { border-radius: var(--br-pill); background-color: white; box-shadow: @@ -55,7 +55,7 @@ } @media (hover: hover) { - &:not([data-selected='true']):hover { + &:not([data-selected]):hover { background-color: var(--gray-container-3); } } diff --git a/docs/src/components/demo/Demo.module.css b/docs/src/components/demo/Demo.module.css index ab17c2c5c7..f151931d7f 100644 --- a/docs/src/components/demo/Demo.module.css +++ b/docs/src/components/demo/Demo.module.css @@ -81,13 +81,13 @@ height: 0; } to { - height: var(--collapsible-content-height); + height: var(--collapsible-panel-height); } } @keyframes slideUp { from { - height: var(--collapsible-content-height); + height: var(--collapsible-panel-height); } to { height: 0; @@ -96,12 +96,9 @@ .collapsible { overflow: hidden; + animation: slideUp 200ms ease-in; &[data-open] { animation: slideDown 200ms ease-out; } - - &[data-closed] { - animation: slideUp 200ms ease-in; - } } diff --git a/docs/src/components/demo/Demo.tsx b/docs/src/components/demo/Demo.tsx index 9edd7fc4ba..ad8e8b3cb5 100644 --- a/docs/src/components/demo/Demo.tsx +++ b/docs/src/components/demo/Demo.tsx @@ -86,13 +86,13 @@ export function Demo(props: DemoProps) { - +
-
+ ); diff --git a/docs/src/components/demo/DemoFileSelector.module.css b/docs/src/components/demo/DemoFileSelector.module.css index 21d47c80e6..81213168a7 100644 --- a/docs/src/components/demo/DemoFileSelector.module.css +++ b/docs/src/components/demo/DemoFileSelector.module.css @@ -28,7 +28,7 @@ cursor: pointer; border: 1px solid transparent; - &[data-selected='true'] { + &[data-selected] { background-color: white; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), @@ -45,7 +45,7 @@ } @media (hover: hover) { - &:not([data-selected='true']):hover { + &:not([data-selected]):hover { background-color: var(--gray-container-3); } } diff --git a/docs/src/design-system/ToggleButtonGroup.module.css b/docs/src/design-system/ToggleButtonGroup.module.css index 51582c82fd..25d097c047 100644 --- a/docs/src/design-system/ToggleButtonGroup.module.css +++ b/docs/src/design-system/ToggleButtonGroup.module.css @@ -31,7 +31,7 @@ cursor: pointer; border: 1px solid transparent; - &[data-selected='true'] { + &[data-selected] { background-color: white; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), diff --git a/docs/src/design-system/ToggleButtonGroup.tsx b/docs/src/design-system/ToggleButtonGroup.tsx index 00ced02b1d..05c86b7957 100644 --- a/docs/src/design-system/ToggleButtonGroup.tsx +++ b/docs/src/design-system/ToggleButtonGroup.tsx @@ -32,7 +32,7 @@ export const ToggleButtonGroup = React.forwardRef(function ToggleButtonGroup< type="button" key={option.value} value={option.value} - data-selected={value === option.value} + data-selected={value === option.value || undefined} aria-pressed={value === option.value || undefined} onClick={() => { setValue(option.value); diff --git a/docs/translations/api-docs/checkbox-group-root/checkbox-group-root.json b/docs/translations/api-docs/checkbox-group-root/checkbox-group-root.json deleted file mode 100644 index b94a0873b5..0000000000 --- a/docs/translations/api-docs/checkbox-group-root/checkbox-group-root.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "componentDescription": "The foundation for building custom-styled checkbox groups.", - "propDescriptions": { - "allValues": { "description": "All values of the checkboxes in the group." }, - "className": { - "description": "Class names applied to the element or a function that returns them based on the component's state." - }, - "defaultValue": { - "description": "The default checked values of the checkbox group. Use when uncontrolled." - }, - "disabled": { "description": "Whether the checkbox group is disabled." }, - "onValueChange": { - "description": "A callback function that is called when the value of the checkbox group changes. Use when controlled." - }, - "preserveChildStates": { - "description": "Whether the parent checkbox should preserve its child states when checked/unchecked, leading to a tri-state checkbox group." - }, - "render": { "description": "A function to customize rendering of the component." }, - "value": { - "description": "The currently checked values of the checkbox group. Use when controlled." - } - }, - "classDescriptions": {} -} diff --git a/docs/translations/api-docs/collapsible-root/collapsible-root.json b/docs/translations/api-docs/collapsible-root/collapsible-root.json deleted file mode 100644 index b45c0ae2b6..0000000000 --- a/docs/translations/api-docs/collapsible-root/collapsible-root.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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." - }, - "defaultOpen": { - "description": "If true, the Collapsible is initially open. This is the uncontrolled counterpart of open." - }, - "disabled": { "description": "If true, the component is disabled." }, - "onOpenChange": { "description": "Callback fired when the Collapsible is opened or closed." }, - "open": { - "description": "If true, the Collapsible is initially open. This is the controlled counterpart of defaultOpen." - }, - "render": { "description": "A function to customize rendering of the component." } - }, - "classDescriptions": {} -} diff --git a/docs/translations/api-docs/radio-group-root/radio-group-root.json b/docs/translations/api-docs/radio-group-root/radio-group-root.json deleted file mode 100644 index bbf977c55b..0000000000 --- a/docs/translations/api-docs/radio-group-root/radio-group-root.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "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 of the selected radio button. Use when uncontrolled." - }, - "disabled": { "description": "Determines if the radio group is disabled." }, - "name": { "description": "The name of the radio group submitted with the form data." }, - "onValueChange": { "description": "Callback fired when the value changes." }, - "readOnly": { "description": "Determines if the radio group is readonly." }, - "render": { "description": "A function to customize rendering of the component." }, - "required": { "description": "Determines if the radio group is required." }, - "value": { "description": "The value of the selected radio button. Use when controlled." } - }, - "classDescriptions": {} -} diff --git a/docs/translations/api-docs/radio-root/radio-root.json b/docs/translations/api-docs/radio-root/radio-root.json deleted file mode 100644 index 04b7711e73..0000000000 --- a/docs/translations/api-docs/radio-root/radio-root.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "className": { - "description": "Class names applied to the element or a function that returns them based on the component's state." - }, - "disabled": { "description": "Determines if the radio is disabled." }, - "readOnly": { "description": "Determines if the radio is readonly." }, - "render": { "description": "A function to customize rendering of the component." }, - "required": { "description": "Determines if the radio is required." }, - "value": { "description": "The unique identifying value of the radio in a group." } - }, - "classDescriptions": {} -} diff --git a/docs/translations/api-docs/use-checkbox-group-parent/use-checkbox-group-parent.json b/docs/translations/api-docs/use-checkbox-group-parent/use-checkbox-group-parent.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-checkbox-group-parent/use-checkbox-group-parent.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/docs/translations/api-docs/use-checkbox-group-root/use-checkbox-group-root.json b/docs/translations/api-docs/use-checkbox-group-root/use-checkbox-group-root.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-checkbox-group-root/use-checkbox-group-root.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/docs/translations/api-docs/use-collapsible-content/use-collapsible-content.json b/docs/translations/api-docs/use-collapsible-content/use-collapsible-content.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-collapsible-content/use-collapsible-content.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/docs/translations/api-docs/use-collapsible-root/use-collapsible-root.json b/docs/translations/api-docs/use-collapsible-root/use-collapsible-root.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-collapsible-root/use-collapsible-root.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/docs/translations/api-docs/use-collapsible-trigger/use-collapsible-trigger.json b/docs/translations/api-docs/use-collapsible-trigger/use-collapsible-trigger.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-collapsible-trigger/use-collapsible-trigger.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/docs/translations/api-docs/use-composite-item/use-composite-item.json b/docs/translations/api-docs/use-composite-item/use-composite-item.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-composite-item/use-composite-item.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/docs/translations/api-docs/use-composite-list-item/use-composite-list-item.json b/docs/translations/api-docs/use-composite-list-item/use-composite-list-item.json deleted file mode 100644 index 6b04319799..0000000000 --- a/docs/translations/api-docs/use-composite-list-item/use-composite-list-item.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "hookDescription": "Used to register a list item and its index (DOM position) in the\n`CompositeList`.", - "parametersDescriptions": {}, - "returnValueDescriptions": {} -} diff --git a/docs/translations/api-docs/use-radio-group-root/use-radio-group-root.json b/docs/translations/api-docs/use-radio-group-root/use-radio-group-root.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-radio-group-root/use-radio-group-root.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/docs/translations/api-docs/use-radio-root/use-radio-root.json b/docs/translations/api-docs/use-radio-root/use-radio-root.json deleted file mode 100644 index e3eb65c6e4..0000000000 --- a/docs/translations/api-docs/use-radio-root/use-radio-root.json +++ /dev/null @@ -1 +0,0 @@ -{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/packages/mui-base/src/Accordion/Header/AccordionHeader.test.tsx b/packages/mui-base/src/Accordion/Header/AccordionHeader.test.tsx new file mode 100644 index 0000000000..9d0be264c5 --- /dev/null +++ b/packages/mui-base/src/Accordion/Header/AccordionHeader.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Accordion } from '@base_ui/react/Accordion'; +import { describeConformance, createRenderer } from '#test-utils'; +import { NOOP } from '../../utils/noop'; +import { CollapsibleRootContext } from '../../Collapsible/Root/CollapsibleRootContext'; +import { AccordionRootContext } from '../Root/AccordionRootContext'; +import { AccordionItemContext } from '../Item/AccordionItemContext'; + +const accordionRootContextValue: AccordionRootContext = { + accordionItemRefs: { current: [] }, + animated: false, + direction: 'ltr', + disabled: false, + handleValueChange: NOOP, + hiddenUntilFound: false, + orientation: 'vertical', + ownerState: { + value: [0], + disabled: false, + orientation: 'vertical', + }, + value: [0], +}; + +const accordionItemContextValue: AccordionItemContext = { + open: true, + ownerState: { + value: [0], + disabled: false, + index: 0, + open: true, + orientation: 'vertical', + transitionStatus: undefined, + }, + setTriggerId: NOOP, + triggerId: ':trigger:', +}; + +const collapsibleContextValue: CollapsibleRootContext = { + animated: false, + panelId: ':panel:', + disabled: false, + mounted: true, + open: true, + setPanelId: NOOP, + setMounted: NOOP, + setOpen: NOOP, + transitionStatus: undefined, + ownerState: { + open: true, + disabled: false, + transitionStatus: undefined, + }, +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => + render( + + + + {node} + + + , + ), + refInstanceof: window.HTMLHeadingElement, + })); +}); diff --git a/packages/mui-base/src/Accordion/Header/AccordionHeader.tsx b/packages/mui-base/src/Accordion/Header/AccordionHeader.tsx new file mode 100644 index 0000000000..1dff33d0ea --- /dev/null +++ b/packages/mui-base/src/Accordion/Header/AccordionHeader.tsx @@ -0,0 +1,63 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { AccordionItem } from '../Item/AccordionItem'; +import { useAccordionItemContext } from '../Item/AccordionItemContext'; +import { accordionStyleHookMapping } from '../Item/styleHooks'; + +/** + * + * Demos: + * + * - [Accordion](https://base-ui.netlify.app/components/react-accordion/) + * + * API: + * + * - [AccordionHeader API](https://base-ui.netlify.app/components/react-accordion/#api-reference-AccordionHeader) + */ +const AccordionHeader = React.forwardRef(function AccordionHeader( + props: AccordionHeader.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...other } = props; + + const { ownerState } = useAccordionItemContext(); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'h3', + ownerState, + className, + ref: forwardedRef, + extraProps: other, + customStyleHookMapping: accordionStyleHookMapping, + }); + + return renderElement(); +}); + +export namespace AccordionHeader { + export interface Props extends BaseUIComponentProps<'h3', AccordionItem.OwnerState> {} +} + +export { AccordionHeader }; + +AccordionHeader.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; diff --git a/packages/mui-base/src/Accordion/Item/AccordionItem.test.tsx b/packages/mui-base/src/Accordion/Item/AccordionItem.test.tsx new file mode 100644 index 0000000000..725f1b5f3e --- /dev/null +++ b/packages/mui-base/src/Accordion/Item/AccordionItem.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Accordion } from '@base_ui/react/Accordion'; +import { describeConformance, createRenderer } from '#test-utils'; +import { NOOP } from '../../utils/noop'; +import { AccordionRootContext } from '../Root/AccordionRootContext'; + +const accordionRootContextValue: AccordionRootContext = { + accordionItemRefs: { current: [] }, + animated: false, + direction: 'ltr', + disabled: false, + handleValueChange: NOOP, + hiddenUntilFound: false, + orientation: 'vertical', + ownerState: { + value: [0], + disabled: false, + orientation: 'vertical', + }, + value: [0], +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => + render( + + {node} + , + ), + refInstanceof: window.HTMLDivElement, + })); +}); diff --git a/packages/mui-base/src/Accordion/Item/AccordionItem.tsx b/packages/mui-base/src/Accordion/Item/AccordionItem.tsx new file mode 100644 index 0000000000..28a8a8a9e9 --- /dev/null +++ b/packages/mui-base/src/Accordion/Item/AccordionItem.tsx @@ -0,0 +1,185 @@ +'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 { CollapsibleRootContext } from '../../Collapsible/Root/CollapsibleRootContext'; +import { useCompositeListItem } from '../../Composite/List/useCompositeListItem'; +import type { AccordionRoot } from '../Root/AccordionRoot'; +import { useAccordionRootContext } from '../Root/AccordionRootContext'; +import { AccordionItemContext } from './AccordionItemContext'; +import { accordionStyleHookMapping } from './styleHooks'; + +/** + * + * Demos: + * + * - [Accordion](https://base-ui.netlify.app/components/react-accordion/) + * + * API: + * + * - [AccordionItem API](https://base-ui.netlify.app/components/react-accordion/#api-reference-AccordionItem) + */ +const AccordionItem = React.forwardRef(function AccordionItem( + props: AccordionItem.Props, + forwardedRef: React.ForwardedRef, +) { + const { + className, + disabled: disabledProp, + onOpenChange: onOpenChangeProp, + render, + value: valueProp, + ...other + } = props; + + const { ref: listItemRef, index } = useCompositeListItem(); + const mergedRef = useForkRef(forwardedRef, listItemRef); + + const { + animated, + disabled: contextDisabled, + handleValueChange, + 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) => { + handleValueChange(value, nextOpen); + 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: CollapsibleRootContext = React.useMemo( + () => ({ + ...collapsible, + ownerState: collapsibleOwnerState, + }), + [collapsible, collapsibleOwnerState], + ); + + const ownerState: AccordionItem.OwnerState = React.useMemo( + () => ({ + ...rootOwnerState, + index, + disabled, + open: isOpen, + transitionStatus: collapsible.transitionStatus, + }), + [collapsible.transitionStatus, disabled, index, isOpen, rootOwnerState], + ); + + const [triggerId, setTriggerId] = React.useState(useId()); + + const accordionItemContext: AccordionItemContext = React.useMemo( + () => ({ + open: isOpen, + ownerState, + setTriggerId, + triggerId, + }), + [isOpen, ownerState, setTriggerId, triggerId], + ); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'div', + className, + ownerState, + ref: mergedRef, + extraProps: other, + customStyleHookMapping: accordionStyleHookMapping, + }); + + return ( + + + {renderElement()} + + + ); +}); + +export namespace AccordionItem { + export type Value = number | string; + + export interface OwnerState extends AccordionRoot.OwnerState { + index: number; + open: boolean; + transitionStatus: TransitionStatus; + } + + export interface Props + extends BaseUIComponentProps, + Pick { + value?: Value; + } +} + +export { AccordionItem }; + +AccordionItem.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; diff --git a/packages/mui-base/src/Accordion/Item/AccordionItemContext.ts b/packages/mui-base/src/Accordion/Item/AccordionItemContext.ts new file mode 100644 index 0000000000..fce915e3ae --- /dev/null +++ b/packages/mui-base/src/Accordion/Item/AccordionItemContext.ts @@ -0,0 +1,28 @@ +'use client'; +import * as React from 'react'; +import type { AccordionItem } from './AccordionItem'; + +export interface AccordionItemContext { + open: boolean; + ownerState: AccordionItem.OwnerState; + setTriggerId: (id: string | undefined) => void; + triggerId?: string; +} + +export const AccordionItemContext = React.createContext( + undefined, +); + +if (process.env.NODE_ENV !== 'production') { + AccordionItemContext.displayName = 'AccordionItemContext'; +} + +export function useAccordionItemContext() { + const context = React.useContext(AccordionItemContext); + if (context === undefined) { + throw new Error( + 'Base UI: AccordionItemContext is missing. Accordion parts must be placed within .', + ); + } + return context; +} diff --git a/packages/mui-base/src/Accordion/Item/styleHooks.ts b/packages/mui-base/src/Accordion/Item/styleHooks.ts new file mode 100644 index 0000000000..fa218385fa --- /dev/null +++ b/packages/mui-base/src/Accordion/Item/styleHooks.ts @@ -0,0 +1,20 @@ +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import { collapsibleOpenStateMapping as baseMapping } from '../../utils/collapsibleOpenStateMapping'; +import type { AccordionItem } from './AccordionItem'; + +export const accordionStyleHookMapping: CustomStyleHookMapping = { + ...baseMapping, + index: (value) => { + return Number.isInteger(value) ? { 'data-index': String(value) } : null; + }, + 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/Panel/AccordionPanel.test.tsx b/packages/mui-base/src/Accordion/Panel/AccordionPanel.test.tsx new file mode 100644 index 0000000000..bf07b284a6 --- /dev/null +++ b/packages/mui-base/src/Accordion/Panel/AccordionPanel.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Accordion } from '@base_ui/react/Accordion'; +import { describeConformance, createRenderer } from '#test-utils'; +import { NOOP } from '../../utils/noop'; +import { CollapsibleRootContext } from '../../Collapsible/Root/CollapsibleRootContext'; +import { AccordionRootContext } from '../Root/AccordionRootContext'; +import { AccordionItemContext } from '../Item/AccordionItemContext'; + +const accordionRootContextValue: AccordionRootContext = { + accordionItemRefs: { current: [] }, + animated: false, + direction: 'ltr', + disabled: false, + handleValueChange: NOOP, + hiddenUntilFound: false, + orientation: 'vertical', + ownerState: { + value: [0], + disabled: false, + orientation: 'vertical', + }, + value: [0], +}; + +const accordionItemContextValue: AccordionItemContext = { + open: true, + ownerState: { + value: [0], + disabled: false, + index: 0, + open: true, + orientation: 'vertical', + transitionStatus: undefined, + }, + setTriggerId: NOOP, + triggerId: ':trigger:', +}; + +const collapsibleContextValue: CollapsibleRootContext = { + animated: false, + panelId: ':panel:', + disabled: false, + mounted: true, + open: true, + setPanelId: NOOP, + setMounted: NOOP, + setOpen: NOOP, + transitionStatus: undefined, + ownerState: { + open: true, + disabled: false, + transitionStatus: undefined, + }, +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => + render( + + + + {node} + + + , + ), + 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..a9ef3ffa8b --- /dev/null +++ b/packages/mui-base/src/Accordion/Panel/AccordionPanel.tsx @@ -0,0 +1,115 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useCollapsibleRootContext } from '../../Collapsible/Root/CollapsibleRootContext'; +import { useCollapsiblePanel } from '../../Collapsible/Panel/useCollapsiblePanel'; +import { useAccordionRootContext } from '../Root/AccordionRootContext'; +import type { AccordionItem } from '../Item/AccordionItem'; +import { useAccordionItemContext } from '../Item/AccordionItemContext'; +import { accordionStyleHookMapping } from '../Item/styleHooks'; + +/** + * + * Demos: + * + * - [Accordion](https://base-ui.netlify.app/components/react-accordion/) + * + * API: + * + * - [AccordionPanel API](https://base-ui.netlify.app/components/react-accordion/#api-reference-AccordionPanel) + */ +const AccordionPanel = React.forwardRef(function AccordionPanel( + props: AccordionPanel.Props, + forwardedRef: React.ForwardedRef, +) { + const { + className, + hiddenUntilFound: hiddenUntilFoundProp, + id: idProp, + render, + style: styleProp, + ...otherProps + } = props; + + const { animated, mounted, open, panelId, setPanelId, setMounted, setOpen } = + useCollapsibleRootContext(); + + const { hiddenUntilFound } = useAccordionRootContext(); + + const { getRootProps, height, width } = useCollapsiblePanel({ + animated, + hiddenUntilFound: hiddenUntilFoundProp || hiddenUntilFound, + id: idProp ?? panelId, + mounted, + open, + ref: forwardedRef, + setPanelId, + setMounted, + setOpen, + }); + + const { ownerState, triggerId } = useAccordionItemContext(); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + ownerState, + className, + extraProps: { + ...otherProps, + 'aria-labelledby': triggerId, + role: 'region', + style: { + '--accordion-panel-height': height ? `${height}px` : undefined, + '--accordion-panel-width': width ? `${width}px` : undefined, + ...styleProp, + }, + }, + customStyleHookMapping: accordionStyleHookMapping, + }); + + return renderElement(); +}); + +export namespace AccordionPanel { + export interface Props + extends BaseUIComponentProps<'div', AccordionItem.OwnerState>, + Pick {} +} + +export { AccordionPanel }; + +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]), + /** + * If `true`, sets `hidden="until-found"` when closed. + * If `false`, sets `hidden` when closed. + * @default false + */ + hiddenUntilFound: PropTypes.bool, + /** + * @ignore + */ + id: PropTypes.string, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + style: PropTypes.object, +} as any; 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..bc129ce470 --- /dev/null +++ b/packages/mui-base/src/Accordion/Root/AccordionRoot.test.tsx @@ -0,0 +1,791 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { createRenderer, describeSkipIf } from '@mui/internal-test-utils'; +import { Accordion } from '@base_ui/react/Accordion'; +import { describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render, + refInstanceof: window.HTMLDivElement, + })); + + describe('ARIA attributes', () => { + it('renders correct ARIA attributes', async () => { + const { getByRole, getByTestId } = await render( + + + + Trigger 1 + + + This is the contents of Accordion.Panel 1 + + + , + ); + + const root = getByTestId('root'); + const trigger = getByRole('button'); + const panel = getByTestId('panel'); + + expect(root).to.have.attribute('role', 'region'); + expect(trigger).to.have.attribute('id', 'Trigger1'); + expect(trigger).to.have.attribute('aria-controls', 'Panel1'); + expect(panel).to.have.attribute('role', 'region'); + expect(panel).to.have.attribute('id', 'Panel1'); + expect(panel).to.have.attribute('aria-labelledby', 'Trigger1'); + }); + }); + + describe('uncontrolled', () => { + it('open state', async function test(t = {}) { + if (/jsdom/.test(window.navigator.userAgent)) { + // @ts-expect-error to support mocha and vitest + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this?.skip?.() || t?.skip(); + } + + const { getByRole, queryByText, user } = await render( + + + + Trigger 1 + + Panel contents + + , + ); + + const trigger = getByRole('button'); + const panel = queryByText('Panel contents'); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(panel).not.toBeVisible(); + + await user.pointer({ keys: '[MouseLeft]', target: trigger }); + + expect(trigger).to.have.attribute('aria-expanded', 'true'); + expect(trigger).to.have.attribute('data-panel-open'); + expect(panel).toBeVisible(); + expect(panel).to.have.attribute('data-open'); + + await user.pointer({ keys: '[MouseLeft]', target: trigger }); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(panel).not.toBeVisible(); + }); + + describe('prop: defaultValue', () => { + it('default item value', async () => { + const { queryByText } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const panel1 = queryByText('Panel contents 1'); + const panel2 = queryByText('Panel contents 2'); + + expect(panel1).not.toBeVisible(); + expect(panel2).toBeVisible(); + expect(panel2).to.have.attribute('data-open'); + }); + + it('custom item value', async () => { + const { queryByText } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const panel1 = queryByText('Panel contents 1'); + const panel2 = queryByText('Panel contents 2'); + + expect(panel1).toBeVisible(); + expect(panel1).to.have.attribute('data-open'); + expect(panel2).not.toBeVisible(); + }); + }); + }); + + describe('controlled', () => { + it('open state', async () => { + const { getByRole, queryByText, setProps } = await render( + + + + Trigger 1 + + Panel contents 1 + + , + ); + + const trigger = getByRole('button'); + const panel = queryByText('Panel contents 1'); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(panel).not.toBeVisible(); + + setProps({ value: [0] }); + + expect(trigger).to.have.attribute('aria-expanded', 'true'); + expect(trigger).to.have.attribute('data-panel-open'); + expect(panel).toBeVisible(); + expect(panel).to.have.attribute('data-open'); + + setProps({ value: [] }); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(panel).not.toBeVisible(); + }); + + describe('prop: value', () => { + it('default item value', async () => { + const { queryByText } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const panel1 = queryByText('Panel contents 1'); + const panel2 = queryByText('Panel contents 2'); + + expect(panel1).not.toBeVisible(); + expect(panel2).toBeVisible(); + expect(panel2).to.have.attribute('data-open'); + }); + + it('custom item value', async () => { + const { queryByText } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const panel1 = queryByText('Panel contents 1'); + const panel2 = queryByText('Panel contents 2'); + + expect(panel1).toBeVisible(); + expect(panel1).to.have.attribute('data-open'); + expect(panel2).not.toBeVisible(); + }); + }); + }); + + describe('prop: disabled', () => { + it('can disable the whole accordion', async () => { + const { getByTestId, queryByText } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const item1 = getByTestId('item1'); + const header1 = getByTestId('header1'); + const trigger1 = getByTestId('trigger1'); + const panel1 = queryByText('Panel contents 1'); + const item2 = getByTestId('item2'); + const header2 = getByTestId('header2'); + const trigger2 = getByTestId('trigger2'); + const panel2 = queryByText('Panel contents 2'); + + [item1, header1, trigger1, panel1, item2, header2, trigger2, panel2].forEach((element) => { + expect(element).to.have.attribute('data-disabled'); + }); + }); + + it('can disable one accordion item', async () => { + const { getByTestId, queryByText } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const item1 = getByTestId('item1'); + const header1 = getByTestId('header1'); + const trigger1 = getByTestId('trigger1'); + const panel1 = queryByText('Panel contents 1'); + const item2 = getByTestId('item2'); + const header2 = getByTestId('header2'); + const trigger2 = getByTestId('trigger2'); + const panel2 = queryByText('Panel contents 2'); + + [item1, header1, trigger1, panel1].forEach((element) => { + expect(element).to.have.attribute('data-disabled'); + }); + [item2, header2, trigger2, panel2].forEach((element) => { + expect(element).to.not.have.attribute('data-disabled'); + }); + }); + }); + + describeSkipIf(/jsdom/.test(window.navigator.userAgent))('keyboard interactions', () => { + ['Enter', 'Space'].forEach((key) => { + it(`key: ${key} toggles the Accordion open state`, async () => { + const { getByRole, queryByText, user } = await render( + + + + Trigger 1 + + Panel contents 1 + + , + ); + + const trigger = getByRole('button'); + const panel = queryByText('Panel contents 1'); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + + expect(panel).not.toBeVisible(); + + await user.keyboard('[Tab]'); + expect(trigger).toHaveFocus(); + await user.keyboard(`[${key}]`); + + expect(trigger).to.have.attribute('aria-expanded', 'true'); + expect(trigger).to.have.attribute('data-panel-open'); + expect(panel).toBeVisible(); + expect(panel).to.have.attribute('data-open'); + + await user.keyboard(`[${key}]`); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(panel).not.toBeVisible(); + }); + }); + + it('ArrowUp and ArrowDown moves focus between triggers and loops by default', async () => { + const { getByTestId, user } = await render( + + + + Trigger 1 + + This is the contents of Accordion.Panel 1 + + + + Trigger 2 + + This is the contents of Accordion.Panel 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + await user.keyboard('[Tab]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + expect(trigger2).toHaveFocus(); + + await user.keyboard('[ArrowUp]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + expect(trigger2).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + expect(trigger1).toHaveFocus(); + }); + + it('Arrow keys should not put focus on disabled accordion items', async () => { + const { getByTestId, user } = await render( + + + + 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 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger3 = getByTestId('trigger3'); + + await user.keyboard('[Tab]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + expect(trigger3).toHaveFocus(); + + await user.keyboard('[ArrowUp]'); + expect(trigger1).toHaveFocus(); + }); + + describe('key: End/Home', () => { + it('End key moves focus the last trigger', async () => { + const { getByTestId, user } = await render( + + + + 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 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const triggerFour = getByTestId('triggerFour'); + + await user.keyboard('[Tab]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[End]'); + expect(triggerFour).toHaveFocus(); + }); + + it('Home key moves focus to the first trigger', async () => { + const { getByTestId, user } = await render( + + + + 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 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const triggerFour = getByTestId('triggerFour'); + + await user.pointer({ keys: '[MouseLeft]', target: triggerFour }); + expect(triggerFour).toHaveFocus(); + + await user.keyboard('[Home]'); + expect(trigger1).toHaveFocus(); + }); + }); + + describe('prop: loop', () => { + it('can disable focus looping between triggers', async () => { + const { getByTestId, user } = await render( + + + + Trigger 1 + + This is the contents of Accordion.Panel 1 + + + + Trigger 2 + + This is the contents of Accordion.Panel 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + await user.keyboard('[Tab]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + expect(trigger2).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + expect(trigger2).toHaveFocus(); + }); + }); + }); + + describeSkipIf(/jsdom/.test(window.navigator.userAgent))('prop: openMultiple', () => { + it('multiple items can be open by default', async () => { + const { getByTestId, queryByText, user } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const panel1 = queryByText('Panel contents 1'); + const trigger2 = getByTestId('trigger2'); + const panel2 = queryByText('Panel contents 2'); + + [trigger1, panel1, trigger2, panel2].forEach((element) => { + expect(element).to.not.have.attribute('data-open'); + }); + + await user.pointer({ keys: '[MouseLeft]', target: trigger1 }); + await user.pointer({ keys: '[MouseLeft]', target: trigger2 }); + + expect(panel1).to.have.attribute('data-open'); + expect(panel2).to.have.attribute('data-open'); + expect(trigger1).to.have.attribute('data-panel-open'); + expect(trigger2).to.have.attribute('data-panel-open'); + }); + + it('when false only one item can be open', async () => { + const { getByTestId, queryByText, user } = await render( + + + + Trigger 1 + + Panel contents 1 + + + + Trigger 2 + + Panel contents 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const panel1 = queryByText('Panel contents 1'); + const trigger2 = getByTestId('trigger2'); + const panel2 = queryByText('Panel contents 2'); + + expect(panel1).to.not.have.attribute('data-open'); + expect(panel2).to.not.have.attribute('data-open'); + expect(trigger1).to.not.have.attribute('data-panel-open'); + expect(trigger2).to.not.have.attribute('data-panel-open'); + + await user.pointer({ keys: '[MouseLeft]', target: trigger1 }); + + expect(panel1).to.have.attribute('data-open'); + expect(trigger1).to.have.attribute('data-panel-open'); + + await user.pointer({ keys: '[MouseLeft]', target: trigger2 }); + + expect(panel2).to.have.attribute('data-open'); + expect(trigger2).to.have.attribute('data-panel-open'); + expect(panel1).to.not.have.attribute('data-open'); + expect(trigger1).to.not.have.attribute('data-panel-open'); + }); + }); + + describeSkipIf(/jsdom/.test(window.navigator.userAgent))('horizontal orientation', () => { + it('ArrowLeft/Right moves focus in horizontal orientation', async () => { + const { getByTestId, user } = await render( + + + + Trigger 1 + + This is the contents of Accordion.Panel 1 + + + + Trigger 2 + + This is the contents of Accordion.Panel 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + await user.keyboard('[Tab]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowRight]'); + expect(trigger2).toHaveFocus(); + + await user.keyboard('[ArrowLeft]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowRight]'); + expect(trigger2).toHaveFocus(); + + await user.keyboard('[ArrowRight]'); + expect(trigger1).toHaveFocus(); + }); + + describeSkipIf(/jsdom/.test(window.navigator.userAgent))('RTL', () => { + it('ArrowLeft/Right is reversed for horizontal accordions in RTL mode', async () => { + const { getByTestId, user } = await render( + + + + Trigger 1 + + This is the contents of Accordion.Panel 1 + + + + Trigger 2 + + This is the contents of Accordion.Panel 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + await user.keyboard('[Tab]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowLeft]'); + expect(trigger2).toHaveFocus(); + + await user.keyboard('[ArrowRight]'); + expect(trigger1).toHaveFocus(); + + await user.keyboard('[ArrowLeft]'); + expect(trigger2).toHaveFocus(); + + await user.keyboard('[ArrowLeft]'); + expect(trigger1).toHaveFocus(); + }); + }); + }); + + describeSkipIf(/jsdom/.test(window.navigator.userAgent))('prop: onValueChange', () => { + it('default item value', async () => { + const onValueChange = spy(); + + const { getByTestId, user } = await render( + + + + Trigger 1 + + This is the contents of Accordion.Panel 1 + + + + Trigger 2 + + This is the contents of Accordion.Panel 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + expect(onValueChange.callCount).to.equal(0); + + await user.pointer({ keys: '[MouseLeft]', target: trigger1 }); + + expect(onValueChange.callCount).to.equal(1); + expect(onValueChange.args[0][0]).to.deep.equal([0]); + + await user.pointer({ keys: '[MouseLeft]', target: trigger2 }); + + expect(onValueChange.callCount).to.equal(2); + expect(onValueChange.args[1][0]).to.deep.equal([0, 1]); + }); + + it('custom item value', async () => { + const onValueChange = spy(); + + const { getByTestId, user } = await render( + + + + Trigger 1 + + This is the contents of Accordion.Panel 1 + + + + Trigger 2 + + This is the contents of Accordion.Panel 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + expect(onValueChange.callCount).to.equal(0); + + await user.pointer({ keys: '[MouseLeft]', target: trigger2 }); + + expect(onValueChange.callCount).to.equal(1); + expect(onValueChange.args[0][0]).to.deep.equal(['two']); + + await user.pointer({ keys: '[MouseLeft]', target: trigger1 }); + + expect(onValueChange.callCount).to.equal(2); + expect(onValueChange.args[1][0]).to.deep.equal(['two', 'one']); + }); + + it('openMultiple is false', async () => { + const onValueChange = spy(); + + const { getByTestId, user } = await render( + + + + Trigger 1 + + This is the contents of Accordion.Panel 1 + + + + Trigger 2 + + This is the contents of Accordion.Panel 2 + + , + ); + + const trigger1 = getByTestId('trigger1'); + const trigger2 = getByTestId('trigger2'); + + expect(onValueChange.callCount).to.equal(0); + + await user.pointer({ keys: '[MouseLeft]', target: trigger1 }); + + expect(onValueChange.callCount).to.equal(1); + expect(onValueChange.args[0][0]).to.deep.equal(['one']); + + await user.pointer({ keys: '[MouseLeft]', target: trigger2 }); + + expect(onValueChange.callCount).to.equal(2); + expect(onValueChange.args[1][0]).to.deep.equal(['two']); + }); + }); +}); 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..fc8e0b755f --- /dev/null +++ b/packages/mui-base/src/Accordion/Root/AccordionRoot.tsx @@ -0,0 +1,181 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { CompositeList } from '../../Composite/List/CompositeList'; +import { useAccordionRoot } from './useAccordionRoot'; +import { AccordionRootContext } from './AccordionRootContext'; + +const rootStyleHookMapping = { + value: () => null, +}; + +/** + * + * Demos: + * + * - [Accordion](https://base-ui.netlify.app/components/react-accordion/) + * + * API: + * + * - [AccordionRoot API](https://base-ui.netlify.app/components/react-accordion/#api-reference-AccordionRoot) + */ +const AccordionRoot = React.forwardRef(function AccordionRoot( + props: AccordionRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { + animated, + className, + direction, + disabled = false, + hiddenUntilFound = false, + loop, + onValueChange, + openMultiple = true, + orientation, + value, + defaultValue: defaultValueProp, + render, + ...otherProps + } = props; + + // memoized to allow omitting both defaultValue and value + // which would otherwise trigger a warning in useControlled + const defaultValue = React.useMemo(() => { + if (value === undefined) { + return defaultValueProp ?? []; + } + + return undefined; + }, [value, defaultValueProp]); + + const { getRootProps, ...accordion } = useAccordionRoot({ + animated, + direction, + disabled, + defaultValue, + loop, + orientation, + onValueChange, + openMultiple, + value, + }); + + const ownerState: AccordionRoot.OwnerState = React.useMemo( + () => ({ + value: accordion.value, + disabled: accordion.disabled, + orientation: accordion.orientation, + }), + [accordion.value, accordion.disabled, accordion.orientation], + ); + + const contextValue: AccordionRootContext = React.useMemo( + () => ({ + ...accordion, + hiddenUntilFound, + ownerState, + }), + [accordion, hiddenUntilFound, ownerState], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + className, + ownerState, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: rootStyleHookMapping, + }); + + return ( + + {renderElement()} + + ); +}); + +export namespace AccordionRoot { + export interface OwnerState { + value: useAccordionRoot.Value; + disabled: boolean; + orientation: useAccordionRoot.Orientation; + } + + export interface Props + extends useAccordionRoot.Parameters, + BaseUIComponentProps { + hiddenUntilFound?: boolean; + } +} + +export { AccordionRoot }; + +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.Item` + * 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 + */ + hiddenUntilFound: PropTypes.bool, + /** + * If `true`, focus will loop when moving focus between `Trigger`s using + * the arrow keys. + * @default true + */ + loop: PropTypes.bool, + /** + * Callback fired when an Accordion section is opened or closed. + * The value representing the involved section is provided as an argument. + */ + onValueChange: PropTypes.func, + /** + * Whether multiple Accordion sections can be opened at the same time + * @default true + */ + openMultiple: PropTypes.bool, + /** + * @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.Item` + * This is the controlled counterpart of `defaultValue`. + */ + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired), +} as any; diff --git a/packages/mui-base/src/Accordion/Root/AccordionRootContext.ts b/packages/mui-base/src/Accordion/Root/AccordionRootContext.ts new file mode 100644 index 0000000000..dda094d48b --- /dev/null +++ b/packages/mui-base/src/Accordion/Root/AccordionRootContext.ts @@ -0,0 +1,27 @@ +'use client'; +import * as React from 'react'; +import type { AccordionRoot } from './AccordionRoot'; +import type { useAccordionRoot } from './useAccordionRoot'; + +export interface AccordionRootContext extends Omit { + ownerState: AccordionRoot.OwnerState; + hiddenUntilFound: boolean; +} + +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( + 'Base UI: AccordionRootContext is missing. Accordion parts must be placed within .', + ); + } + return context; +} 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..b4c875824b --- /dev/null +++ b/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts @@ -0,0 +1,267 @@ +'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 getActiveTriggers(accordionItemRefs: { + current: (HTMLElement | null)[]; +}): HTMLButtonElement[] { + const { current: accordionItemElements } = accordionItemRefs; + + const output: HTMLButtonElement[] = []; + + for (let i = 0; i < accordionItemElements.length; i += 1) { + const section = accordionItemElements[i]; + if (!isDisabled(section)) { + const trigger = section?.querySelector('[type="button"]') as HTMLButtonElement; + if (!isDisabled(trigger)) { + output.push(trigger); + } + } + } + + return output; +} + +function isDisabled(element: HTMLElement | null) { + return ( + element === null || + element.hasAttribute('disabled') || + element.getAttribute('data-disabled') === 'true' + ); +} + +export function useAccordionRoot( + parameters: useAccordionRoot.Parameters, +): useAccordionRoot.ReturnValue { + const { + animated = true, + disabled = false, + direction = 'ltr', + loop = true, + onValueChange = NOOP, + orientation = 'vertical', + openMultiple = true, + value: valueParam, + defaultValue, + } = parameters; + + const accordionItemRefs = React.useRef<(HTMLElement | null)[]>([]); + + const [value, setValue] = useControlled({ + controlled: valueParam, + default: defaultValue, + name: 'Accordion', + state: 'value', + }); + + const handleValueChange = React.useCallback( + (newValue: number | string, nextOpen: boolean) => { + if (!openMultiple) { + const nextValue = value[0] === newValue ? [] : [newValue]; + setValue(nextValue); + onValueChange(nextValue); + } else if (nextOpen) { + const nextOpenValues = value.slice(); + nextOpenValues.push(newValue); + setValue(nextOpenValues); + onValueChange(nextOpenValues); + } else { + const nextOpenValues = value.filter((v) => v !== newValue); + setValue(nextOpenValues); + onValueChange(nextOpenValues); + } + }, + [onValueChange, 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; + } + + event.preventDefault(); + + const triggers = getActiveTriggers(accordionItemRefs); + + const numOfEnabledTriggers = triggers.length; + const lastIndex = numOfEnabledTriggers - 1; + + let nextIndex = -1; + + const thisIndex = triggers.indexOf(event.target as HTMLButtonElement); + + function toNext() { + if (loop) { + nextIndex = thisIndex + 1 > lastIndex ? 0 : thisIndex + 1; + } else { + nextIndex = Math.min(thisIndex + 1, lastIndex); + } + } + + function toPrev() { + if (loop) { + nextIndex = thisIndex === 0 ? lastIndex : thisIndex - 1; + } else { + 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) { + triggers[nextIndex].focus(); + } + }, + }); + }, + [direction, loop, orientation], + ); + + return React.useMemo( + () => ({ + getRootProps, + accordionItemRefs, + animated, + direction, + disabled, + handleValueChange, + orientation, + value, + }), + [ + getRootProps, + accordionItemRefs, + animated, + direction, + disabled, + handleValueChange, + 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.Item` + * This is the controlled counterpart of `defaultValue`. + */ + value?: Value; + /** + * The default value representing the currently open `Accordion.Item` + * 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; + /** + * If `true`, focus will loop when moving focus between `Trigger`s using + * the arrow keys. + * @default true + */ + loop?: boolean; + /** + * Callback fired when an Accordion section is opened or closed. + * The value representing the involved section is provided as an argument. + */ + onValueChange?: (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'>; + accordionItemRefs: React.MutableRefObject<(HTMLElement | null)[]>; + animated: boolean; + direction: Direction; + /** + * The disabled state of the Accordion + */ + disabled: boolean; + handleValueChange: (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/Trigger/AccordionTrigger.test.tsx b/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.test.tsx new file mode 100644 index 0000000000..c29db1b65a --- /dev/null +++ b/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Accordion } from '@base_ui/react/Accordion'; +import { describeConformance, createRenderer } from '#test-utils'; +import { NOOP } from '../../utils/noop'; +import { CollapsibleRootContext } from '../../Collapsible/Root/CollapsibleRootContext'; +import { AccordionRootContext } from '../Root/AccordionRootContext'; +import { AccordionItemContext } from '../Item/AccordionItemContext'; + +const accordionRootContextValue: AccordionRootContext = { + accordionItemRefs: { current: [] }, + animated: false, + direction: 'ltr', + disabled: false, + handleValueChange: NOOP, + hiddenUntilFound: false, + orientation: 'vertical', + ownerState: { + value: [0], + disabled: false, + orientation: 'vertical', + }, + value: [0], +}; + +const accordionItemContextValue: AccordionItemContext = { + open: true, + ownerState: { + value: [0], + disabled: false, + index: 0, + open: true, + orientation: 'vertical', + transitionStatus: undefined, + }, + setTriggerId: NOOP, + triggerId: ':trigger:', +}; + +const collapsibleContextValue: CollapsibleRootContext = { + animated: false, + panelId: ':panel:', + disabled: false, + mounted: true, + open: true, + setPanelId: NOOP, + setMounted: NOOP, + setOpen: NOOP, + transitionStatus: undefined, + ownerState: { + open: true, + disabled: false, + transitionStatus: undefined, + }, +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => + render( + + + + {node} + + + , + ), + 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..41c5428f2a --- /dev/null +++ b/packages/mui-base/src/Accordion/Trigger/AccordionTrigger.tsx @@ -0,0 +1,93 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { triggerOpenStateMapping } from '../../utils/collapsibleOpenStateMapping'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useCollapsibleRootContext } from '../../Collapsible/Root/CollapsibleRootContext'; +import { useCollapsibleTrigger } from '../../Collapsible/Trigger/useCollapsibleTrigger'; +import type { AccordionItem } from '../Item/AccordionItem'; +import { useAccordionItemContext } from '../Item/AccordionItemContext'; + +/** + * + * Demos: + * + * - [Accordion](https://base-ui.netlify.app/components/react-accordion/) + * + * API: + * + * - [AccordionTrigger API](https://base-ui.netlify.app/components/react-accordion/#api-reference-AccordionTrigger) + */ + +const AccordionTrigger = React.forwardRef(function AccordionTrigger( + props: AccordionTrigger.Props, + forwardedRef: React.ForwardedRef, +) { + const { disabled: disabledProp, className, id, render, ...otherProps } = props; + + const { panelId, disabled: contextDisabled, open, setOpen } = useCollapsibleRootContext(); + + const { getRootProps } = useCollapsibleTrigger({ + panelId, + disabled: disabledProp || contextDisabled, + id, + open, + rootRef: forwardedRef, + setOpen, + }); + + const { ownerState, setTriggerId, triggerId } = useAccordionItemContext(); + + useEnhancedEffect(() => { + setTriggerId(id); + return () => { + setTriggerId(undefined); + }; + }, [id, setTriggerId]); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'button', + ownerState, + className, + extraProps: { ...otherProps, id: triggerId }, + customStyleHookMapping: triggerOpenStateMapping, + }); + + return renderElement(); +}); + +namespace AccordionTrigger { + export interface Props extends BaseUIComponentProps<'button', AccordionItem.OwnerState> {} +} + +export { AccordionTrigger }; + +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, + /** + * @ignore + */ + id: PropTypes.string, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; diff --git a/packages/mui-base/src/Accordion/index.parts.ts b/packages/mui-base/src/Accordion/index.parts.ts new file mode 100644 index 0000000000..cfd89a887a --- /dev/null +++ b/packages/mui-base/src/Accordion/index.parts.ts @@ -0,0 +1,5 @@ +export { AccordionRoot as Root } from './Root/AccordionRoot'; +export { AccordionItem as Item } from './Item/AccordionItem'; +export { AccordionHeader as Header } from './Header/AccordionHeader'; +export { AccordionTrigger as Trigger } from './Trigger/AccordionTrigger'; +export { AccordionPanel as Panel } 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..bc75beb631 --- /dev/null +++ b/packages/mui-base/src/Accordion/index.ts @@ -0,0 +1 @@ +export * as Accordion from './index.parts'; diff --git a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.test.tsx b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.test.tsx index 3ed5243e25..b9155d2b2b 100644 --- a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.test.tsx +++ b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.test.tsx @@ -192,16 +192,16 @@ describe('', () => { expect(checkbox).to.have.attribute('data-checked', ''); expect(checkbox).not.to.have.attribute('data-unchecked'); - expect(checkbox).to.have.attribute('data-disabled', 'true'); - expect(checkbox).to.have.attribute('data-readonly', 'true'); - expect(checkbox).to.have.attribute('data-required', 'true'); + expect(checkbox).to.have.attribute('data-disabled', ''); + expect(checkbox).to.have.attribute('data-readonly', ''); + expect(checkbox).to.have.attribute('data-required', ''); expect(indicator).to.have.attribute('data-checked', ''); expect(indicator).not.to.have.attribute('data-unchecked'); - expect(indicator).to.have.attribute('data-disabled', 'true'); - expect(indicator).to.have.attribute('data-readonly', 'true'); - expect(indicator).to.have.attribute('data-required', 'true'); + expect(indicator).to.have.attribute('data-disabled', ''); + expect(indicator).to.have.attribute('data-readonly', ''); + expect(indicator).to.have.attribute('data-required', ''); setProps({ disabled: false, readOnly: false }); fireEvent.click(checkbox); diff --git a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.test.tsx b/packages/mui-base/src/Collapsible/Panel/CollapsiblePanel.test.tsx similarity index 86% rename from packages/mui-base/src/Collapsible/Content/CollapsibleContent.test.tsx rename to packages/mui-base/src/Collapsible/Panel/CollapsiblePanel.test.tsx index 4f32f61663..0bea82d66a 100644 --- a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.test.tsx +++ b/packages/mui-base/src/Collapsible/Panel/CollapsiblePanel.test.tsx @@ -6,11 +6,11 @@ import { CollapsibleRootContext } from '../Root/CollapsibleRootContext'; const contextValue: CollapsibleRootContext = { animated: false, - contentId: 'ContentId', + panelId: 'PanelId', disabled: false, mounted: true, open: true, - setContentId() {}, + setPanelId() {}, setMounted() {}, setOpen() {}, transitionStatus: undefined, @@ -21,10 +21,10 @@ const contextValue: CollapsibleRootContext = { }, }; -describe('', () => { +describe('', () => { const { render } = createRenderer(); - describeConformance(, () => ({ + describeConformance(, () => ({ inheritComponent: 'div', render: (node) => { const { container, ...other } = render( diff --git a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.tsx b/packages/mui-base/src/Collapsible/Panel/CollapsiblePanel.tsx similarity index 64% rename from packages/mui-base/src/Collapsible/Content/CollapsibleContent.tsx rename to packages/mui-base/src/Collapsible/Panel/CollapsiblePanel.tsx index 3aa9f21a7a..4d7a0f7be8 100644 --- a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.tsx +++ b/packages/mui-base/src/Collapsible/Panel/CollapsiblePanel.tsx @@ -3,10 +3,10 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { BaseUIComponentProps } from '../../utils/types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import { useCollapsibleContext } from '../Root/CollapsibleRootContext'; +import { useCollapsibleRootContext } from '../Root/CollapsibleRootContext'; import type { CollapsibleRoot } from '../Root/CollapsibleRoot'; import { collapsibleStyleHookMapping } from '../Root/styleHooks'; -import { useCollapsibleContent } from './useCollapsibleContent'; +import { useCollapsiblePanel } from './useCollapsiblePanel'; /** * @@ -16,25 +16,25 @@ import { useCollapsibleContent } from './useCollapsibleContent'; * * API: * - * - [CollapsibleContent API](https://base-ui.netlify.app/components/react-collapsible/#api-reference-CollapsibleContent) + * - [CollapsiblePanel API](https://base-ui.netlify.app/components/react-collapsible/#api-reference-CollapsiblePanel) */ -const CollapsibleContent = React.forwardRef(function CollapsibleContent( - props: CollapsibleContent.Props, +const CollapsiblePanel = React.forwardRef(function CollapsiblePanel( + props: CollapsiblePanel.Props, forwardedRef: React.ForwardedRef, ) { - const { className, htmlHidden, render, ...otherProps } = props; + const { className, hiddenUntilFound, render, ...otherProps } = props; - const { animated, mounted, open, contentId, setContentId, setMounted, setOpen, ownerState } = - useCollapsibleContext(); + const { animated, mounted, open, panelId, setPanelId, setMounted, setOpen, ownerState } = + useCollapsibleRootContext(); - const { getRootProps, height } = useCollapsibleContent({ + const { getRootProps, height, width } = useCollapsiblePanel({ animated, - htmlHidden, - id: contentId, + hiddenUntilFound, + id: panelId, mounted, open, ref: forwardedRef, - setContentId, + setPanelId, setMounted, setOpen, }); @@ -48,7 +48,8 @@ const CollapsibleContent = React.forwardRef(function CollapsibleContent( ...otherProps, style: { ...otherProps.style, - '--collapsible-content-height': height ? `${height}px` : undefined, + '--collapsible-panel-height': height ? `${height}px` : undefined, + '--collapsible-panel-width': width ? `${width}px` : undefined, }, }, customStyleHookMapping: collapsibleStyleHookMapping, @@ -57,13 +58,15 @@ const CollapsibleContent = React.forwardRef(function CollapsibleContent( return renderElement(); }); -namespace CollapsibleContent { +export { CollapsiblePanel }; + +namespace CollapsiblePanel { export interface Props extends BaseUIComponentProps<'div', CollapsibleRoot.OwnerState>, - Pick {} + Pick {} } -CollapsibleContent.propTypes /* remove-proptypes */ = { +CollapsiblePanel.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ @@ -77,10 +80,11 @@ CollapsibleContent.propTypes /* remove-proptypes */ = { */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** - * The hidden state when closed - * @default 'hidden' + * If `true`, sets `hidden="until-found"` when closed. + * If `false`, sets `hidden` when closed. + * @default false */ - htmlHidden: PropTypes.oneOf(['hidden', 'until-found']), + hiddenUntilFound: PropTypes.bool, /** * A function to customize rendering of the component. */ @@ -90,5 +94,3 @@ CollapsibleContent.propTypes /* remove-proptypes */ = { */ style: PropTypes.object, } as any; - -export { CollapsibleContent }; diff --git a/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts b/packages/mui-base/src/Collapsible/Panel/useCollapsiblePanel.ts similarity index 65% rename from packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts rename to packages/mui-base/src/Collapsible/Panel/useCollapsiblePanel.ts index b7b23f67a2..7b7f1946bd 100644 --- a/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts +++ b/packages/mui-base/src/Collapsible/Panel/useCollapsiblePanel.ts @@ -9,9 +9,25 @@ import { useAnimationsFinished } from '../../utils/useAnimationsFinished'; import { useForkRef } from '../../utils/useForkRef'; import { useId } from '../../utils/useId'; -function getComputedStyles(element: HTMLElement) { +let cachedSupportsComputedStyleMap: boolean | undefined; + +function supportsComputedStyleMap(element: HTMLElement) { + if (cachedSupportsComputedStyleMap === undefined) { + cachedSupportsComputedStyleMap = 'computedStyleMap' in element; + } + return cachedSupportsComputedStyleMap; +} + +function getAnimationNameFromComputedStyles(element: HTMLElement) { + if (supportsComputedStyleMap(element)) { + const styleMap = element.computedStyleMap(); + const animationName = styleMap.get('animation-name'); + return (animationName as CSSKeywordValue)?.value ?? undefined; + } + const containerWindow = ownerWindow(element); - return containerWindow.getComputedStyle(element); + const computedStyles = containerWindow.getComputedStyle(element); + return computedStyles.animationName; } let cachedSupportsHiddenUntilFound: boolean | undefined; @@ -32,26 +48,34 @@ function supportsHiddenUntilFound(element: HTMLElement) { return cachedSupportsHiddenUntilFound; } -export function useCollapsibleContent( - parameters: useCollapsibleContent.Parameters, -): useCollapsibleContent.ReturnValue { +interface Dimensions { + height: number; + width: number; +} + +export function useCollapsiblePanel( + parameters: useCollapsiblePanel.Parameters, +): useCollapsiblePanel.ReturnValue { const { animated = false, - htmlHidden = 'hidden', + hiddenUntilFound = false, id: idParam, open, mounted: contextMounted, ref, - setContentId, + setPanelId, setMounted: setContextMounted, setOpen, } = parameters; const id = useId(idParam); - const contentRef = React.useRef(null); + const panelRef = React.useRef(null); - const [height, setHeight] = React.useState(0); + const [{ height, width }, setDimensions] = React.useState({ + height: 0, + width: 0, + }); const latestAnimationNameRef = React.useRef('none'); const originalTransitionDurationStyleRef = React.useRef(null); @@ -59,28 +83,28 @@ export function useCollapsibleContent( const isTransitioningRef = React.useRef(false); useEnhancedEffect(() => { - setContentId(id); + setPanelId(id); return () => { - setContentId(undefined); + setPanelId(undefined); }; - }, [id, setContentId]); + }, [id, setPanelId]); - const handleContentRef = useEventCallback((element: HTMLElement) => { + const handlePanelRef = useEventCallback((element: HTMLElement) => { if (!element) { return; } - contentRef.current = element; + panelRef.current = element; - const computedStyles = getComputedStyles(element); + const computedAnimationName = getAnimationNameFromComputedStyles(element); - latestAnimationNameRef.current = computedStyles.animationName ?? 'none'; + latestAnimationNameRef.current = computedAnimationName ?? 'none'; originalTransitionDurationStyleRef.current = element.style.transitionDuration; }); - const mergedRef = useForkRef(ref, handleContentRef); + const mergedRef = useForkRef(ref, handlePanelRef); - const runOnceAnimationsFinish = useAnimationsFinished(contentRef); + const runOnceAnimationsFinish = useAnimationsFinished(panelRef); const isOpen = animated ? open || contextMounted : open; @@ -89,14 +113,12 @@ export function useCollapsibleContent( const isBeforeMatchRef = React.useRef(false); useEnhancedEffect(() => { - const { current: element } = contentRef; + const { current: element } = panelRef; let frame1 = -1; let frame2 = -1; if (element) { - const computedStyles = getComputedStyles(element); - const currentAnimationName = computedStyles.animationName; const isBeforeMatch = isBeforeMatchRef.current; const isInitiallyOpen = isInitialOpenRef.current; const isTransitioning = isTransitioningRef.current; @@ -110,10 +132,14 @@ export function useCollapsibleContent( element.style.animationName = 'none'; - const rect = element.getBoundingClientRect(); + const isClosed = !open && !contextMounted; - if (!isTransitioning || !(open || contextMounted)) { - setHeight(rect.height); + if (!isTransitioning || isClosed) { + const rect = isClosed ? { height: 0, width: 0 } : element.getBoundingClientRect(); + setDimensions({ + height: rect.height, + width: rect.width, + }); } element.style.animationName = shouldCancelAnimation ? 'none' : originalAnimationName; @@ -134,10 +160,6 @@ export function useCollapsibleContent( } }); }); - - if (currentAnimationName !== 'none') { - latestAnimationNameRef.current = currentAnimationName; - } } return () => { @@ -147,7 +169,7 @@ export function useCollapsibleContent( }, [open, contextMounted, runOnceAnimationsFinish, setContextMounted]); React.useEffect(() => { - const { current: element } = contentRef; + const { current: element } = panelRef; let frame2 = -1; let frame3 = -1; @@ -172,43 +194,40 @@ export function useCollapsibleContent( }; }, []); - React.useEffect( - function registerCssTransitionListeners() { - const { current: element } = contentRef; - if (!element) { - return undefined; - } + React.useEffect(function registerCssTransitionListeners() { + const { current: element } = panelRef; + if (!element) { + return undefined; + } - function handleTransitionRun() { - isTransitioningRef.current = true; - } + function handleTransitionRun() { + isTransitioningRef.current = true; + } - function handleTransitionEnd() { - isTransitioningRef.current = false; - } + function handleTransitionEnd() { + isTransitioningRef.current = false; + } - function handleTransitionCancel() { - isTransitioningRef.current = false; - } + function handleTransitionCancel() { + isTransitioningRef.current = false; + } - element.addEventListener('transitioncancel', handleTransitionCancel); - element.addEventListener('transitionend', handleTransitionEnd); - element.addEventListener('transitionrun', handleTransitionRun); + element.addEventListener('transitioncancel', handleTransitionCancel); + element.addEventListener('transitionend', handleTransitionEnd); + element.addEventListener('transitionrun', handleTransitionRun); - return () => { - element.removeEventListener('transitioncancel', handleTransitionCancel); - element.removeEventListener('transitionend', handleTransitionEnd); - element.removeEventListener('transitionrun', handleTransitionRun); - }; - }, - [open, setHeight], - ); + return () => { + element.removeEventListener('transitioncancel', handleTransitionCancel); + element.removeEventListener('transitionend', handleTransitionEnd); + element.removeEventListener('transitionrun', handleTransitionRun); + }; + }, []); // if `hidden="until-found"` content is revealed by browser's in-page search // we need to manually sync the open state React.useEffect( function registerBeforeMatchListener() { - const { current: element } = contentRef; + const { current: element } = panelRef; if (!element || !supportsHiddenUntilFound(element)) { return undefined; @@ -236,40 +255,43 @@ export function useCollapsibleContent( // so we have to force it back to `'until-found'` in the DOM when applicable // https://github.com/facebook/react/issues/24740 useEnhancedEffect(() => { - const { current: element } = contentRef; + const { current: element } = panelRef; if ( element && supportsHiddenUntilFound(element) && element?.hidden && !isOpen && - htmlHidden === 'until-found' + hiddenUntilFound === true ) { // @ts-ignore element.hidden = 'until-found'; } - }, [htmlHidden, isOpen]); + }, [hiddenUntilFound, isOpen]); - const getRootProps: useCollapsibleContent.ReturnValue['getRootProps'] = React.useCallback( + const hidden = hiddenUntilFound ? 'until-found' : 'hidden'; + + const getRootProps: useCollapsiblePanel.ReturnValue['getRootProps'] = React.useCallback( (externalProps = {}) => mergeReactProps(externalProps, { id, - hidden: isOpen ? undefined : htmlHidden, + hidden: isOpen ? undefined : hidden, ref: mergedRef, }), - [htmlHidden, id, isOpen, mergedRef], + [hidden, id, isOpen, mergedRef], ); return React.useMemo( () => ({ getRootProps, height, + width, }), - [getRootProps, height], + [getRootProps, height, width], ); } -export namespace useCollapsibleContent { +export namespace useCollapsiblePanel { export interface Parameters { /** * If `true`, the component supports CSS/JS-based animations and transitions. @@ -277,10 +299,11 @@ export namespace useCollapsibleContent { */ animated?: boolean; /** - * The hidden state when closed - * @default 'hidden' + * If `true`, sets `hidden="until-found"` when closed. + * If `false`, sets `hidden` when closed. + * @default false */ - htmlHidden?: 'hidden' | 'until-found'; + hiddenUntilFound?: boolean; id?: React.HTMLAttributes['id']; mounted: boolean; /** @@ -288,7 +311,7 @@ export namespace useCollapsibleContent { */ open: boolean; ref: React.Ref; - setContentId: (id: string | undefined) => void; + setPanelId: (id: string | undefined) => void; setOpen: (nextOpen: boolean) => void; setMounted: (nextMounted: boolean) => void; } @@ -298,5 +321,6 @@ export namespace useCollapsibleContent { externalProps?: React.ComponentPropsWithRef<'button'>, ) => React.ComponentPropsWithRef<'button'>; height: number; + width: number; } } diff --git a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx index 3dfd9742c2..e1d4f05d38 100644 --- a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx +++ b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx @@ -22,46 +22,45 @@ describe('', () => { const { getByTestId, getByRole } = await render( - + , ); const trigger = getByRole('button'); - const content = getByTestId('content'); + const panel = getByTestId('panel'); expect(trigger).to.have.attribute('aria-expanded'); - expect(trigger.getAttribute('aria-controls')).to.equal(content.getAttribute('id')); + expect(trigger.getAttribute('aria-controls')).to.equal(panel.getAttribute('id')); }); }); describe('open state', () => { it('controlled mode', async () => { - const { getByTestId, getByRole, setProps } = await render( + const { queryByText, getByRole, setProps } = await render( - + This is content , ); const trigger = getByRole('button'); - const content = getByTestId('content'); + const panel = queryByText('This is content'); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('hidden'); - expect(content).to.have.attribute('data-closed', ''); + expect(panel).not.toBeVisible(); setProps({ open: true }); expect(trigger).to.have.attribute('aria-expanded', 'true'); - expect(content).to.not.have.attribute('hidden'); - expect(content).to.have.attribute('data-open', ''); + expect(panel).toBeVisible(); + expect(panel).to.have.attribute('data-open'); + expect(trigger).to.have.attribute('data-panel-open'); setProps({ open: false }); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('data-closed', ''); - expect(content).to.have.attribute('hidden'); + expect(panel).not.toBeVisible(); }); it('uncontrolled mode', async function test(t = {}) { @@ -71,70 +70,70 @@ describe('', () => { this?.skip?.() || t?.skip(); } - const { getByTestId, getByRole, user } = await render( + const { getByRole, queryByText, user } = await render( - + This is content , ); const trigger = getByRole('button'); - const content = getByTestId('content'); + const panel = queryByText('This is content'); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('hidden'); - expect(content).to.have.attribute('data-closed', ''); + expect(panel).not.toBeVisible(); await user.pointer({ keys: '[MouseLeft]', target: trigger }); expect(trigger).to.have.attribute('aria-expanded', 'true'); - expect(content).to.not.have.attribute('hidden'); - expect(content).to.have.attribute('data-open', ''); + expect(panel).toBeVisible(); + expect(panel).to.have.attribute('data-open'); + expect(trigger).to.have.attribute('data-panel-open'); await user.pointer({ keys: '[MouseLeft]', target: trigger }); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('data-closed', ''); - expect(content).to.have.attribute('hidden'); + expect(trigger).to.not.have.attribute('data-panel-open'); + expect(panel).not.toBeVisible(); }); }); describeSkipIf(/jsdom/.test(window.navigator.userAgent))('keyboard interactions', () => { ['Enter', 'Space'].forEach((key) => { it(`key: ${key} should toggle the Collapsible`, async () => { - const { getByTestId, getByRole, user } = await render( + const { queryByText, getByRole, user } = await render( Trigger - + This is content , ); const trigger = getByRole('button'); - const content = getByTestId('content'); + const panel = queryByText('This is content'); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('hidden'); - expect(content).to.have.attribute('data-closed', ''); + expect(panel).not.toBeVisible(); await user.keyboard('[Tab]'); expect(trigger).toHaveFocus(); await user.keyboard(`[${key}]`); expect(trigger).to.have.attribute('aria-expanded', 'true'); - expect(content).to.not.have.attribute('hidden'); - expect(content).to.have.attribute('data-open', ''); + expect(trigger).to.have.attribute('data-panel-open'); + expect(panel).toBeVisible(); + expect(panel).to.have.attribute('data-open'); await user.keyboard(`[${key}]`); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('data-closed', ''); - expect(content).to.have.attribute('hidden'); + expect(trigger).not.to.have.attribute('data-panel-open'); + expect(panel).not.toBeVisible(); }); }); }); - describe('prop: htmlHidden', () => { - it('supports "hidden until found" state', async function test(t = {}) { + describe('prop: hiddenUntilFound', () => { + it('uses `hidden="until-found" to hide panel when true', async function test(t = {}) { // we test firefox in browserstack which does not support this yet if (!('onbeforematch' in window) || /jsdom/.test(window.navigator.userAgent)) { // @ts-expect-error to support mocha and vitest @@ -144,27 +143,25 @@ describe('', () => { const handleOpenChange = spy(); - const { getByTestId } = await render( + const { queryByText } = await render( - + This is content , ); - const content = getByTestId('content'); - - expect(content).to.have.attribute('data-closed', ''); + const panel = queryByText('This is content'); act(() => { const event = new window.Event('beforematch', { bubbles: true, cancelable: false, }); - content.dispatchEvent(event); + panel?.dispatchEvent(event); }); expect(handleOpenChange.callCount).to.equal(1); - expect(content).to.have.attribute('data-open', ''); + expect(panel).to.have.attribute('data-open'); }); }); }); diff --git a/packages/mui-base/src/Collapsible/Root/CollapsibleRootContext.ts b/packages/mui-base/src/Collapsible/Root/CollapsibleRootContext.ts index 9fcc9bd7df..d19cffcb76 100644 --- a/packages/mui-base/src/Collapsible/Root/CollapsibleRootContext.ts +++ b/packages/mui-base/src/Collapsible/Root/CollapsibleRootContext.ts @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import type { CollapsibleRoot } from './CollapsibleRoot'; import type { useCollapsibleRoot } from './useCollapsibleRoot'; +import type { CollapsibleRoot } from './CollapsibleRoot'; export interface CollapsibleRootContext extends useCollapsibleRoot.ReturnValue { ownerState: CollapsibleRoot.OwnerState; @@ -15,7 +15,7 @@ if (process.env.NODE_ENV !== 'production') { CollapsibleRootContext.displayName = 'CollapsibleRootContext'; } -export function useCollapsibleContext() { +export function useCollapsibleRootContext() { const context = React.useContext(CollapsibleRootContext); if (context === undefined) { throw new Error( diff --git a/packages/mui-base/src/Collapsible/Root/styleHooks.ts b/packages/mui-base/src/Collapsible/Root/styleHooks.ts index 3590d508e2..483cc786ef 100644 --- a/packages/mui-base/src/Collapsible/Root/styleHooks.ts +++ b/packages/mui-base/src/Collapsible/Root/styleHooks.ts @@ -1,11 +1,10 @@ import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import { collapsibleOpenStateMapping as baseMapping } from '../../utils/collapsibleOpenStateMapping'; import type { CollapsibleRoot } from './CollapsibleRoot'; export const collapsibleStyleHookMapping: CustomStyleHookMapping = { - open(value): Record { - return value ? { 'data-open': '' } : { 'data-closed': '' }; - }, - transitionStatus(value) { + ...baseMapping, + transitionStatus: (value) => { if (value === 'entering') { return { 'data-entering': '' } as Record; } diff --git a/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts b/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts index 3467b1e129..d40e21d105 100644 --- a/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts +++ b/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts @@ -25,7 +25,7 @@ export function useCollapsibleRoot( const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, animated); - const [contentId, setContentId] = React.useState(useId()); + const [panelId, setPanelId] = React.useState(useId()); const setOpen = useEventCallback((nextOpen: boolean) => { onOpenChange?.(nextOpen); @@ -35,26 +35,16 @@ export function useCollapsibleRoot( return React.useMemo( () => ({ animated, - contentId, + panelId, disabled, mounted, open, - setContentId, + setPanelId, setMounted, setOpen, transitionStatus, }), - [ - animated, - contentId, - disabled, - mounted, - open, - setContentId, - setMounted, - setOpen, - transitionStatus, - ], + [animated, panelId, disabled, mounted, open, setPanelId, setMounted, setOpen, transitionStatus], ); } @@ -89,7 +79,7 @@ export namespace useCollapsibleRoot { export interface ReturnValue { animated: boolean; - contentId: React.HTMLAttributes['id']; + panelId: React.HTMLAttributes['id']; /** * The disabled state of the Collapsible */ @@ -99,7 +89,7 @@ export namespace useCollapsibleRoot { * The open state of the Collapsible */ open: boolean; - setContentId: (id: string | undefined) => void; + setPanelId: (id: string | undefined) => void; setMounted: (open: boolean) => void; setOpen: (open: boolean) => void; transitionStatus: TransitionStatus; diff --git a/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx b/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx index 4934f36e3d..289fc2969d 100644 --- a/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx +++ b/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx @@ -6,11 +6,11 @@ import { CollapsibleRootContext } from '../Root/CollapsibleRootContext'; const contextValue: CollapsibleRootContext = { animated: false, - contentId: 'ContentId', + panelId: 'PanelId', disabled: false, mounted: true, open: true, - setContentId() {}, + setPanelId() {}, setMounted() {}, setOpen() {}, transitionStatus: undefined, diff --git a/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.tsx b/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.tsx index 69260638c7..ef59b7b659 100644 --- a/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.tsx +++ b/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.tsx @@ -1,11 +1,11 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import { triggerOpenStateMapping } from '../../utils/collapsibleOpenStateMapping'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { BaseUIComponentProps } from '../../utils/types'; -import { useCollapsibleContext } from '../Root/CollapsibleRootContext'; +import { useCollapsibleRootContext } from '../Root/CollapsibleRootContext'; import { CollapsibleRoot } from '../Root/CollapsibleRoot'; -import { collapsibleStyleHookMapping } from '../Root/styleHooks'; import { useCollapsibleTrigger } from './useCollapsibleTrigger'; /** @@ -24,12 +24,13 @@ const CollapsibleTrigger = React.forwardRef(function CollapsibleTrigger( ) { const { className, render, ...otherProps } = props; - const { contentId, open, setOpen, ownerState } = useCollapsibleContext(); + const { panelId, open, setOpen, ownerState } = useCollapsibleRootContext(); const { getRootProps } = useCollapsibleTrigger({ - contentId, + panelId, open, setOpen, + rootRef: forwardedRef, }); const { renderElement } = useComponentRenderer({ @@ -37,14 +38,15 @@ const CollapsibleTrigger = React.forwardRef(function CollapsibleTrigger( render: render ?? 'button', ownerState, className, - ref: forwardedRef, extraProps: otherProps, - customStyleHookMapping: collapsibleStyleHookMapping, + customStyleHookMapping: triggerOpenStateMapping, }); return renderElement(); }); +export { CollapsibleTrigger }; + namespace CollapsibleTrigger { export interface Props extends BaseUIComponentProps<'button', CollapsibleRoot.OwnerState> {} } @@ -67,5 +69,3 @@ CollapsibleTrigger.propTypes /* remove-proptypes */ = { */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), } as any; - -export { CollapsibleTrigger }; diff --git a/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts b/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts index a9db4730ef..c8a75f9058 100644 --- a/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts +++ b/packages/mui-base/src/Collapsible/Trigger/useCollapsibleTrigger.ts @@ -1,23 +1,43 @@ 'use client'; import * as React from 'react'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useForkRef } from '../../utils/useForkRef'; +import { GenericHTMLProps } from '../../utils/types'; +import { useButton } from '../../useButton'; export function useCollapsibleTrigger( parameters: useCollapsibleTrigger.Parameters, ): useCollapsibleTrigger.ReturnValue { - const { contentId, open, setOpen } = parameters; + const { panelId, disabled, id, open, rootRef: externalRef, setOpen } = parameters; + + const { getButtonProps, buttonRef } = useButton({ + disabled, + focusableWhenDisabled: true, + type: 'button', + }); + + const handleRef = useForkRef(externalRef, buttonRef); const getRootProps: useCollapsibleTrigger.ReturnValue['getRootProps'] = React.useCallback( - (externalProps = {}) => - mergeReactProps<'button'>(externalProps, { - type: 'button', - 'aria-controls': contentId, - 'aria-expanded': open, - onClick() { - setOpen(!open); - }, - }), - [contentId, open, setOpen], + (externalProps: GenericHTMLProps = {}) => + mergeReactProps( + externalProps, + mergeReactProps( + { + type: 'button', + 'aria-controls': panelId, + 'aria-expanded': open, + disabled, + id, + onClick() { + setOpen(!open); + }, + ref: handleRef, + }, + getButtonProps(), + ), + ), + [panelId, disabled, getButtonProps, handleRef, id, open, setOpen], ); return { @@ -30,11 +50,14 @@ export namespace useCollapsibleTrigger { /** * The id of the element controlled by the Trigger */ - contentId: React.HTMLAttributes['id']; + panelId: React.HTMLAttributes['id']; + disabled?: boolean; + id?: React.HTMLAttributes['id']; /** * The open state of the Collapsible */ open: boolean; + rootRef?: React.Ref; /** * A state setter that sets the open state of the Collapsible */ @@ -42,8 +65,6 @@ export namespace useCollapsibleTrigger { } export interface ReturnValue { - getRootProps: ( - externalProps?: React.ComponentPropsWithRef<'button'>, - ) => React.ComponentPropsWithRef<'button'>; + getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; } } diff --git a/packages/mui-base/src/Collapsible/index.parts.ts b/packages/mui-base/src/Collapsible/index.parts.ts index 3736ae4deb..91b27a729e 100644 --- a/packages/mui-base/src/Collapsible/index.parts.ts +++ b/packages/mui-base/src/Collapsible/index.parts.ts @@ -1,3 +1,3 @@ export { CollapsibleRoot as Root } from './Root/CollapsibleRoot'; export { CollapsibleTrigger as Trigger } from './Trigger/CollapsibleTrigger'; -export { CollapsibleContent as Content } from './Content/CollapsibleContent'; +export { CollapsiblePanel as Panel } from './Panel/CollapsiblePanel'; diff --git a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx index ccd533334a..43b71b0859 100644 --- a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx +++ b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx @@ -41,10 +41,10 @@ describe('', () => { const label = screen.getByTestId('label'); const message = screen.getByTestId('message'); - expect(field).to.have.attribute('data-disabled', 'true'); - expect(control).to.have.attribute('data-disabled', 'true'); - expect(label).to.have.attribute('data-disabled', 'true'); - expect(message).to.have.attribute('data-disabled', 'true'); + expect(field).to.have.attribute('data-disabled', ''); + expect(control).to.have.attribute('data-disabled', ''); + expect(label).to.have.attribute('data-disabled', ''); + expect(message).to.have.attribute('data-disabled', ''); }); }); @@ -357,10 +357,10 @@ describe('', () => { fireEvent.focus(control); fireEvent.blur(control); - expect(root).to.have.attribute('data-touched', 'true'); - expect(control).to.have.attribute('data-touched', 'true'); - expect(label).to.have.attribute('data-touched', 'true'); - expect(description).to.have.attribute('data-touched', 'true'); + expect(root).to.have.attribute('data-touched', ''); + expect(control).to.have.attribute('data-touched', ''); + expect(label).to.have.attribute('data-touched', ''); + expect(description).to.have.attribute('data-touched', ''); expect(error).to.equal(null); }); @@ -376,7 +376,7 @@ describe('', () => { fireEvent.focus(button); fireEvent.blur(button); - expect(button).to.have.attribute('data-touched', 'true'); + expect(button).to.have.attribute('data-touched', ''); }); it('supports Switch', () => { @@ -391,7 +391,7 @@ describe('', () => { fireEvent.focus(button); fireEvent.blur(button); - expect(button).to.have.attribute('data-touched', 'true'); + expect(button).to.have.attribute('data-touched', ''); }); it('supports NumberField', () => { @@ -408,7 +408,7 @@ describe('', () => { fireEvent.focus(input); fireEvent.blur(input); - expect(input).to.have.attribute('data-touched', 'true'); + expect(input).to.have.attribute('data-touched', ''); }); it('supports Slider', () => { @@ -428,7 +428,7 @@ describe('', () => { fireEvent.focus(thumb); fireEvent.blur(thumb); - expect(root).to.have.attribute('data-touched', 'true'); + expect(root).to.have.attribute('data-touched', ''); }); it('supports RadioGroup (click)', () => { @@ -448,8 +448,8 @@ describe('', () => { fireEvent.click(control); - expect(group).to.have.attribute('data-touched', 'true'); - expect(control).to.have.attribute('data-touched', 'true'); + expect(group).to.have.attribute('data-touched', ''); + expect(control).to.have.attribute('data-touched', ''); }); it('supports RadioGroup (blur)', async () => { @@ -471,8 +471,8 @@ describe('', () => { await userEvent.tab(); // onto control await userEvent.tab(); // onto last button - expect(group).to.have.attribute('data-touched', 'true'); - expect(control).to.have.attribute('data-touched', 'true'); + expect(group).to.have.attribute('data-touched', ''); + expect(control).to.have.attribute('data-touched', ''); }); }); @@ -499,10 +499,10 @@ describe('', () => { fireEvent.change(control, { target: { value: 'value' } }); - expect(root).to.have.attribute('data-dirty', 'true'); - expect(control).to.have.attribute('data-dirty', 'true'); - expect(label).to.have.attribute('data-dirty', 'true'); - expect(description).to.have.attribute('data-dirty', 'true'); + expect(root).to.have.attribute('data-dirty', ''); + expect(control).to.have.attribute('data-dirty', ''); + expect(label).to.have.attribute('data-dirty', ''); + expect(description).to.have.attribute('data-dirty', ''); fireEvent.change(control, { target: { value: '' } }); @@ -525,7 +525,7 @@ describe('', () => { fireEvent.click(button); - expect(button).to.have.attribute('data-dirty', 'true'); + expect(button).to.have.attribute('data-dirty', ''); }); it('supports Switch', () => { @@ -541,7 +541,7 @@ describe('', () => { fireEvent.click(button); - expect(button).to.have.attribute('data-dirty', 'true'); + expect(button).to.have.attribute('data-dirty', ''); }); it('supports NumberField', () => { @@ -559,7 +559,7 @@ describe('', () => { fireEvent.change(input, { target: { value: '1' } }); - expect(input).to.have.attribute('data-dirty', 'true'); + expect(input).to.have.attribute('data-dirty', ''); }); it('supports Slider', () => { @@ -581,7 +581,7 @@ describe('', () => { fireEvent.change(input, { target: { value: 'value' } }); - expect(root).to.have.attribute('data-dirty', 'true'); + expect(root).to.have.attribute('data-dirty', ''); }); it('supports RadioGroup', () => { @@ -600,7 +600,7 @@ describe('', () => { fireEvent.click(screen.getByText('One')); - expect(group).to.have.attribute('data-dirty', 'true'); + expect(group).to.have.attribute('data-dirty', ''); }); }); }); diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.test.tsx b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.test.tsx index 536d887b95..2a2c38e33a 100644 --- a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.test.tsx +++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.test.tsx @@ -132,19 +132,19 @@ describe('', () => { const item = screen.getByTestId('item'); const indicator = screen.getByTestId('indicator'); - expect(root).to.have.attribute('data-disabled', 'true'); - expect(root).to.have.attribute('data-readonly', 'true'); - expect(root).to.have.attribute('data-required', 'true'); + expect(root).to.have.attribute('data-disabled', ''); + expect(root).to.have.attribute('data-readonly', ''); + expect(root).to.have.attribute('data-required', ''); expect(item).to.have.attribute('data-radio', 'checked'); - expect(item).to.have.attribute('data-disabled', 'true'); - expect(item).to.have.attribute('data-readonly', 'true'); - expect(item).to.have.attribute('data-required', 'true'); + expect(item).to.have.attribute('data-disabled', ''); + expect(item).to.have.attribute('data-readonly', ''); + expect(item).to.have.attribute('data-required', ''); expect(indicator).to.have.attribute('data-radio', 'checked'); - expect(indicator).to.have.attribute('data-disabled', 'true'); - expect(indicator).to.have.attribute('data-readonly', 'true'); - expect(indicator).to.have.attribute('data-required', 'true'); + expect(indicator).to.have.attribute('data-disabled', ''); + expect(indicator).to.have.attribute('data-readonly', ''); + expect(indicator).to.have.attribute('data-required', ''); }); it('should set the name attribute on the input', () => { diff --git a/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx b/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx index 049a8c4cdb..d3d27ff948 100644 --- a/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx +++ b/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx @@ -308,7 +308,7 @@ describeSkipIf(typeof Touch === 'undefined')('', () => { const thumb = getByTestId('thumb'); [root, output, control, track, indicator, thumb].forEach((subcomponent) => { - expect(subcomponent).to.have.attribute('data-disabled', 'true'); + expect(subcomponent).to.have.attribute('data-disabled', ''); }); }); @@ -1091,7 +1091,7 @@ describeSkipIf(typeof Touch === 'undefined')('', () => { createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]), ); - expect(sliderControl).to.have.attribute('data-dragging', 'true'); + expect(sliderControl).to.have.attribute('data-dragging', ''); fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 0, clientY: 0 }])); expect(sliderControl).to.not.have.attribute('data-dragging'); }); diff --git a/packages/mui-base/src/Switch/Root/SwitchRoot.test.tsx b/packages/mui-base/src/Switch/Root/SwitchRoot.test.tsx index 8f92f6fa11..e352c16bbb 100644 --- a/packages/mui-base/src/Switch/Root/SwitchRoot.test.tsx +++ b/packages/mui-base/src/Switch/Root/SwitchRoot.test.tsx @@ -258,14 +258,14 @@ describe('', () => { const thumb = screen.getByTestId('thumb'); expect(switchElement).to.have.attribute('data-checked', ''); - expect(switchElement).to.have.attribute('data-disabled', 'true'); - expect(switchElement).to.have.attribute('data-readonly', 'true'); - expect(switchElement).to.have.attribute('data-required', 'true'); + expect(switchElement).to.have.attribute('data-disabled', ''); + expect(switchElement).to.have.attribute('data-readonly', ''); + expect(switchElement).to.have.attribute('data-required', ''); expect(thumb).to.have.attribute('data-checked', ''); - expect(thumb).to.have.attribute('data-disabled', 'true'); - expect(thumb).to.have.attribute('data-readonly', 'true'); - expect(thumb).to.have.attribute('data-required', 'true'); + expect(thumb).to.have.attribute('data-disabled', ''); + expect(thumb).to.have.attribute('data-readonly', ''); + expect(thumb).to.have.attribute('data-required', ''); setProps({ disabled: false, readOnly: false }); fireEvent.click(switchElement); diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts index 5c2fbaeff3..00b01aa772 100644 --- a/packages/mui-base/src/index.ts +++ b/packages/mui-base/src/index.ts @@ -1,3 +1,4 @@ +export * from './Accordion'; export * from './AlertDialog'; export * from './Checkbox'; export * from './CheckboxGroup'; diff --git a/packages/mui-base/src/utils/collapsibleOpenStateMapping.ts b/packages/mui-base/src/utils/collapsibleOpenStateMapping.ts new file mode 100644 index 0000000000..6f35357a7a --- /dev/null +++ b/packages/mui-base/src/utils/collapsibleOpenStateMapping.ts @@ -0,0 +1,23 @@ +import type { CustomStyleHookMapping } from './getStyleHookProps'; + +export const triggerOpenStateMapping: CustomStyleHookMapping<{ open: boolean }> = { + open(value) { + if (value) { + return { + 'data-panel-open': '', + }; + } + return null; + }, +}; + +export const collapsibleOpenStateMapping: CustomStyleHookMapping<{ open: boolean }> = { + open(value) { + if (value) { + return { + 'data-open': '', + }; + } + return null; + }, +}; diff --git a/packages/mui-base/src/utils/defaultRenderFunctions.tsx b/packages/mui-base/src/utils/defaultRenderFunctions.tsx index 2dc3130608..6bcdd575e0 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 ; }, diff --git a/packages/mui-base/src/utils/getStyleHookProps.test.ts b/packages/mui-base/src/utils/getStyleHookProps.test.ts index 8a797c8ddd..5b998195e8 100644 --- a/packages/mui-base/src/utils/getStyleHookProps.test.ts +++ b/packages/mui-base/src/utils/getStyleHookProps.test.ts @@ -11,7 +11,7 @@ describe('getStyleHookProps', () => { const result = getStyleHookProps(state); expect(result).to.deep.equal({ - 'data-checked': 'true', + 'data-checked': '', 'data-orientation': 'vertical', 'data-count': '42', }); @@ -24,10 +24,20 @@ describe('getStyleHookProps', () => { const result = getStyleHookProps(state); expect(result).to.deep.equal({ - 'data-readonly': 'true', + 'data-readonly': '', }); }); + it('changes true values to a data-attribute without a value', () => { + const state = { + required: true, + disabled: false, + }; + + const result = getStyleHookProps(state); + expect(result).to.deep.equal({ 'data-required': '' }); + }); + it('does not include false values', () => { const state = { required: true, diff --git a/packages/mui-base/src/utils/getStyleHookProps.ts b/packages/mui-base/src/utils/getStyleHookProps.ts index 0246d9da6a..ca94e03c4b 100644 --- a/packages/mui-base/src/utils/getStyleHookProps.ts +++ b/packages/mui-base/src/utils/getStyleHookProps.ts @@ -18,7 +18,9 @@ export function getStyleHookProps>( return; } - if (value) { + if (value === true) { + props[`data-${key.toLowerCase()}`] = ''; + } else if (value) { props[`data-${key.toLowerCase()}`] = value.toString(); } });