diff --git a/docs/data/base/components/accordion/UnstyledAccordionIntroduction.js b/docs/data/base/components/accordion/UnstyledAccordionIntroduction.js index dcfddb6413..33efa25cbf 100644 --- a/docs/data/base/components/accordion/UnstyledAccordionIntroduction.js +++ b/docs/data/base/components/accordion/UnstyledAccordionIntroduction.js @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import * as Accordion from '@base_ui/react/Accordion'; @@ -122,7 +123,7 @@ function Styles() { margin: 12px auto 12px 0; } - .Accordion-trigger[data-state="open"] svg { + .Accordion-trigger[data-collapsible="open"] svg { transform: rotate(180deg); } diff --git a/docs/data/base/components/accordion/UnstyledAccordionIntroduction.tsx b/docs/data/base/components/accordion/UnstyledAccordionIntroduction.tsx index dcfddb6413..33efa25cbf 100644 --- a/docs/data/base/components/accordion/UnstyledAccordionIntroduction.tsx +++ b/docs/data/base/components/accordion/UnstyledAccordionIntroduction.tsx @@ -1,3 +1,4 @@ +'use client'; import * as React from 'react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import * as Accordion from '@base_ui/react/Accordion'; @@ -122,7 +123,7 @@ function Styles() { margin: 12px auto 12px 0; } - .Accordion-trigger[data-state="open"] svg { + .Accordion-trigger[data-collapsible="open"] svg { transform: rotate(180deg); } diff --git a/docs/data/base/components/accordion/accordion.md b/docs/data/base/components/accordion/accordion.md index 08d86d4335..70ebe45b7e 100644 --- a/docs/data/base/components/accordion/accordion.md +++ b/docs/data/base/components/accordion/accordion.md @@ -60,23 +60,15 @@ Accordions are implemented using a collection of related components: - - Toggle one - + Toggle one - - Section one content - + Section one content - - Toggle two - + Toggle two - - Section two content - + Section two content ``` @@ -94,23 +86,15 @@ You can optionally specify a custom `value` prop on `Section`: - - Toggle one - + Toggle one - - Section one content - + Section one content - - Toggle two - + Toggle two - - Section two content - + Section two content ``` @@ -121,51 +105,35 @@ When uncontrolled, use the `defaultValue` prop to set the initial state of the a ```tsx - {/* `value={0}` by default */} + - - Toggle one - + Toggle one - - Section one content - + Section one content - {/* `value={1}` by default */} + - - Toggle two - + Toggle two - - Section two content - + Section two content - +; -{/* with custom `value`s */} +// with custom `value`s - - Toggle one - + Toggle one - - Section one content - + Section one content - - Toggle two - + Toggle two - - Section two content - + Section two content - +; ``` ### Controlled @@ -179,26 +147,18 @@ return ( - - Toggle one - + Toggle one - - Section one content - + Section one content - - Toggle two - + Toggle two - - Section two content - + Section two content -) +); ``` ## Customization @@ -208,9 +168,7 @@ return ( By default, all accordion sections can be opened at the same time. Use the `openMultiple` prop to only allow one open section at a time: ```tsx - - {/* subcomponents */} - +{/* subcomponents */} ``` ### At least one section remains open @@ -222,15 +180,15 @@ const [value, setValue] = React.useState([0]); const handleOpenChange = (newValue) => { if (newValue.length > 0) { - setValue(newValue) + setValue(newValue); } -} +}; return ( {/* subcomponents */} -) +); ``` ## Horizontal @@ -238,9 +196,7 @@ return ( 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 */} - +{/* subcomponents */} ``` ## RTL @@ -248,9 +204,152 @@ Use the `orientation` prop to configure a horizontal accordion. In a horizontal Use the `direction` prop to configure a RTL accordion: ```tsx - - {/* subcomponents */} - +{/* 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 + +:::warning +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 `htmlHidden` prop to improve searchability: + +```js +{/* subcomponents */} +``` + +Alternatively `htmlHidden` can be passed to `Accordion.Panel` directly to enable this for only one section 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`](/base-ui/react-collapsible/) internally, and can be animated in a [similar way](/base-ui/react-collapsible/#animations). + +Four states are available as data attributes to animate the `Accordion.Panel`: + +- `[data-collapsible="open"]` - `open` state is `true`. +- `[data-collapsible="closed"]` - `open` state is `false`. Can still be mounted to the DOM if closing. +- `[data-entering]` - the `hidden` attribute was just removed from the DOM and the content element participates in page layout. The `data-entering` attribute will be removed 1 animation frame later. +- `[data-exiting]` - the content 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 + +The dimensions of the `Accordion.Panel` subcomponent are provided as the `--accordion-content-height` and `--accordion-content-width` CSS variables. + +### CSS Animations + +CSS animations can be used with two declarations: + +```css +.Accordion-panel { + overflow: hidden; +} + +.Accordion-panel[data-collapsible='open'] { + animation: slideDown 300ms ease-out; +} + +.Accordion-panel[data-collapsible='closed'] { + animation: slideUp 300ms ease-in; +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--accordion-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--accordion-content-height); + } + to { + height: 0; + } +} +``` + +### CSS Transitions + +When using CSS transitions, styles for the `Panel` must be applied to three states: + +- The closed styles with `[data-collapsible="closed"]` +- The open styles with `[data-collapsible="open"]` +- The entering styles with `[data-entering]` + +```css +.Accordion-panel { + overflow: hidden; +} + +.Accordion-panel[data-collapsible='open'] { + height: var(--accordion-content-height); + transition: height 300ms ease-out; +} + +.Accordion-panel[data-entering] { + height: 0; +} + +.Accordion-panel[data-collapsible='closed'] { + height: 0; + transition: height 300ms ease-in; +} +``` + +### JavaScript Animations + +When using external libraries for animation, for example `framer-motion`, be aware that `Accordion.Section`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 sections */} + + ); +} +``` diff --git a/docs/data/base/components/collapsible/collapsible.md b/docs/data/base/components/collapsible/collapsible.md index 50f843d7d5..c57483f582 100644 --- a/docs/data/base/components/collapsible/collapsible.md +++ b/docs/data/base/components/collapsible/collapsible.md @@ -97,7 +97,7 @@ The component can be animate when opening or closing using either: - CSS transitions - JavaScript animations -The height of the `Content` subcomponent is provided as the `--collapsible-content-height` CSS variable +The dimensions of the `Content` subcomponent are provided as the `--collapsible-content-height` and `--collapsible-content-width` CSS variables ### CSS Animations @@ -150,7 +150,7 @@ When using CSS transitions, styles for the `Content` subcomponent must be applie overflow: hidden; } -.Collapsible2-content[data-state='open'] { +.Collapsible-content[data-state='open'] { height: var(--collapsible-content-height); transition: height 300ms ease-out; } @@ -159,7 +159,7 @@ When using CSS transitions, styles for the `Content` subcomponent must be applie height: 0; } -.Collapsible2-content[data-state='closed'] { +.Collapsible-content[data-state='closed'] { height: 0; transition: height 300ms ease-in; } diff --git a/docs/pages/experiments/accordion-animations.tsx b/docs/pages/experiments/accordion-animations.tsx new file mode 100644 index 0000000000..a5d8fa4d8a --- /dev/null +++ b/docs/pages/experiments/accordion-animations.tsx @@ -0,0 +1,189 @@ +import * as React from 'react'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import * as Accordion from '@base_ui/react/Accordion'; + +const DURATION = '300ms'; + +export default function App() { + return ( +
+

CSS @keyframe animations + `hidden="until-found"`

+ + {[0, 1, 2].map((index) => ( + + + + Trigger {index + 1} + + + + +

+ This is the contents of Accordion.Panel {index + 1} +
+ It uses `hidden="until-found"` and can be opened by the browser's + in-page search +

+
+
+ ))} +
+ +

CSS transitions

+ + {[0, 1, 2].map((index) => ( + + + + Trigger {index + 1} + + + + +

This is the contents of Accordion.Panel {index + 1}

+
+
+ ))} +
+ +
+ ); +} + +function MaterialStyles() { + return ( + + ); +} diff --git a/docs/pages/experiments/accordion-material.tsx b/docs/pages/experiments/accordion-material.tsx deleted file mode 100644 index 3130c291ac..0000000000 --- a/docs/pages/experiments/accordion-material.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import * as React from 'react'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import * as Accordion from '@base_ui/react/Accordion'; - -export default function App() { - return ( -
- - {[0, 1, 2, 3].map((index) => ( - - - - Trigger {index + 1} - - - - - This is the contents of Accordion.Panel {index + 1} - - - ))} - - -
- ); -} - -function MaterialStyles() { - return ( - - ); -} diff --git a/packages/mui-base/src/Accordion/Root/styleHooks.ts b/packages/mui-base/src/Accordion/Root/styleHooks.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts b/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts index 7a591fb2df..041726228a 100644 --- a/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts +++ b/packages/mui-base/src/Accordion/Root/useAccordionRoot.ts @@ -7,6 +7,26 @@ import { ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT } from '../../Composite/c const SUPPORTED_KEYS = [ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT, 'Home', 'End']; +function getActiveTriggers(accordionSectionRefs: { + current: (HTMLElement | null)[]; +}): HTMLButtonElement[] { + const { current: accordionSectionElements } = accordionSectionRefs; + + const output: HTMLButtonElement[] = []; + + for (let i = 0; i < accordionSectionElements.length; i += 1) { + const section = accordionSectionElements[i]; + if (!isDisabled(section)) { + const trigger = section?.querySelector('[type="button"]') as HTMLButtonElement; + if (!isDisabled(trigger)) { + output.push(trigger); + } + } + } + + return output; +} + function isDisabled(element: HTMLElement | null) { return ( element === null || @@ -49,8 +69,6 @@ export function useAccordionRoot( const handleOpenChange = React.useCallback( (newValue: number | string, nextOpen: boolean) => { - // console.group('useAccordionRoot handleOpenChange'); - // console.log('newValue', newValue, 'nextOpen', nextOpen, 'openValues', value); if (!openMultiple) { const nextValue = value[0] === newValue ? [] : [newValue]; setValue(nextValue); @@ -58,16 +76,13 @@ export function useAccordionRoot( } else if (nextOpen) { const nextOpenValues = value.slice(); nextOpenValues.push(newValue); - // console.log('nextOpenValues', nextOpenValues); setValue(nextOpenValues); onOpenChange(nextOpenValues); } else { const nextOpenValues = value.filter((v) => v !== newValue); - // console.log('nextOpenValues', nextOpenValues); setValue(nextOpenValues); onOpenChange(nextOpenValues); } - // console.groupEnd(); }, [onOpenChange, openMultiple, setValue, value], ); @@ -84,21 +99,9 @@ export function useAccordionRoot( return; } - // console.group('onKeyDown'); - const { current: accordionSectionElements } = accordionSectionRefs; - - // TODO: memo this outside - const triggers: HTMLButtonElement[] = []; + event.preventDefault(); - for (let i = 0; i < accordionSectionElements.length; i += 1) { - const section = accordionSectionElements[i]; - if (!isDisabled(section)) { - const trigger = section?.querySelector('[type="button"]') as HTMLButtonElement; - if (!isDisabled(trigger)) { - triggers.push(trigger); - } - } - } + const triggers = getActiveTriggers(accordionSectionRefs); const numOfEnabledTriggers = triggers.length; const lastIndex = numOfEnabledTriggers - 1; @@ -155,10 +158,8 @@ export function useAccordionRoot( } if (nextIndex > -1) { - // console.log('focus nextIndex', nextIndex); triggers[nextIndex].focus(); } - // console.groupEnd(); }, }); }, diff --git a/packages/mui-base/src/Accordion/Section/styleHooks.ts b/packages/mui-base/src/Accordion/Section/styleHooks.ts index cb56d531b6..09c3917327 100644 --- a/packages/mui-base/src/Accordion/Section/styleHooks.ts +++ b/packages/mui-base/src/Accordion/Section/styleHooks.ts @@ -12,7 +12,7 @@ export const accordionStyleHookMapping: CustomStyleHookMapping { - return value ? { 'data-state': 'open' } : { 'data-state': 'closed' }; + return value ? { 'data-collapsible': 'open' } : { 'data-collapsible': 'closed' }; }, transitionStatus: (value) => { if (value === 'entering') {