Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[popups] actionsRef prop #1236

Merged
merged 33 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7a73a79
[popups] Require Portal part
atomiks Dec 24, 2024
7b415f1
Fix experiment
atomiks Dec 24, 2024
2d41b5e
Remove conditional returns in backdrops
atomiks Dec 24, 2024
9a0860a
[POC] Unmount ref
atomiks Dec 27, 2024
befe34c
ummountRef -> action
atomiks Jan 7, 2025
5a8731f
Merge branch 'master' into fix/js-anim
atomiks Jan 7, 2025
c5b4936
lockfile
atomiks Jan 7, 2025
49bf54c
Merge branch 'master' into fix/js-anim
atomiks Jan 7, 2025
5016515
Merge branch 'master' into fix/js-anim
atomiks Jan 10, 2025
f098d2c
Update
atomiks Jan 10, 2025
a8db50d
Add action for all popups
atomiks Jan 10, 2025
aa84338
Require manual open check
atomiks Jan 10, 2025
154ee1e
Merge branch 'master' into fix/js-anim
atomiks Jan 20, 2025
4fd1541
Fix warning
atomiks Jan 20, 2025
8dc3265
Tests
atomiks Jan 20, 2025
77987ed
Add nativeEvent for mergeReactProps test
atomiks Jan 20, 2025
3c836fb
Lint
atomiks Jan 20, 2025
0af3006
Polish animation page
atomiks Jan 27, 2025
516d53f
Merge branch 'master' into fix/js-anim
atomiks Jan 27, 2025
f190f91
Merge
atomiks Feb 5, 2025
95a1052
Merge
atomiks Feb 5, 2025
6c818ea
proptypes
atomiks Feb 5, 2025
deb808a
Remove stale file
atomiks Feb 5, 2025
45dd76e
Check for params.action
atomiks Feb 5, 2025
1886b8f
Fix Menu test
atomiks Feb 5, 2025
8fcc4ba
Update packages/react/src/menu/root/MenuRoot.test.tsx
atomiks Feb 5, 2025
8678ac1
Rename to actions ref
atomiks Feb 5, 2025
8ddf7f0
Export Actions type
atomiks Feb 5, 2025
b939a3a
Merge
atomiks Feb 6, 2025
6aee360
pnpm-lock
atomiks Feb 6, 2025
1641f5f
Add missing actionsRef tests
atomiks Feb 6, 2025
e69ca40
Merge branch 'master' into fix/js-anim
atomiks Feb 7, 2025
b7a095f
Merge branch 'master' into fix/js-anim
atomiks Feb 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@
"@types/react-dom": "^19.0.2",
"@types/unist": "^3.0.3",
"chai": "^4.5.0",
"framer-motion": "^11.18.2",
"fs-extra": "^11.3.0",
"mdast-util-mdx-jsx": "^3.2.0",
"motion": "^11.15.0",
"prettier": "^3.4.2",
"rimraf": "^5.0.10",
"serve": "^14.2.4",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/alert-dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the dialog is opened or closed."
},
"action": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the dialog is opened or closed."
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the dialog is opened or closed."
},
"action": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"dismissible": {
"type": "boolean",
"default": "true",
Expand Down
6 changes: 5 additions & 1 deletion docs/reference/generated/menu-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"type": "(open, event) => void",
"description": "Event handler called when the menu is opened or closed."
},
"action": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"closeParentOnEsc": {
"type": "boolean",
"default": "true",
Expand All @@ -27,7 +31,7 @@
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the menu is opened or closed."
"description": "Event handler called after any animations complete when the menu is closed."
},
"disabled": {
"type": "boolean",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/popover-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the popover is opened or closed."
},
"action": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the popover is opened or closed."
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/preview-card-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the preview card is opened or closed."
},
"action": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the preview card is opened or closed."
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/select-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"type": "(open, event) => void",
"description": "Event handler called when the select menu is opened or closed."
},
"action": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"alignItemToTrigger": {
"type": "boolean",
"default": "true",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/tooltip-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the tooltip is opened or closed."
},
"action": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the tooltip is opened or closed."
Expand Down
2 changes: 1 addition & 1 deletion docs/src/app/(private)/experiments/collapsible-framer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Collapsible } from '@base-ui-components/react/collapsible';
import { motion } from 'framer-motion';
import { motion } from 'motion/react';
import c from './collapsible.module.css';

export default function CollapsibleFramer() {
Expand Down
106 changes: 106 additions & 0 deletions docs/src/app/(private)/experiments/motion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client';
import * as React from 'react';
import { Popover } from '@base-ui-components/react/popover';
import { motion, AnimatePresence } from 'motion/react';

function ConditionallyMounted() {
const [open, setOpen] = React.useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}

function AlwaysMounted() {
const [open, setOpen] = React.useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Trigger</Popover.Trigger>
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={false}
animate={{
scale: open ? 1 : 0,
opacity: open ? 1 : 0,
}}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}

function NoOpacity() {
const [open, setOpen] = React.useState(false);
const actionRef = React.useRef({ unmount: () => {} });

return (
<Popover.Root open={open} onOpenChange={setOpen} action={actionRef}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
onAnimationComplete={() => {
if (!open) {
actionRef.current.unmount();
}
}}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}

export default function Page() {
return (
<div>
<h2>Conditionally mounted</h2>
<ConditionallyMounted />
<h2>Always mounted</h2>
<AlwaysMounted />
<h2>No opacity</h2>
<NoOpacity />
</div>
);
}
2 changes: 1 addition & 1 deletion docs/src/app/(private)/experiments/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as React from 'react';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { styled, keyframes } from '@mui/system';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from 'motion/react';

const scaleIn = keyframes`
from {
Expand Down
127 changes: 125 additions & 2 deletions docs/src/app/(public)/(content)/react/handbook/animation/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,128 @@ Use the following Base UI attributes for creating CSS animations when a compone

## JavaScript animations

JavaScript animation libraries such as [Motion](https://motion.dev) require control of the mounting and unmounting lifecycle of components.
Most Base UI components are unmounted when hidden. These components usually provide the `keepMounted` prop to allow JavaScript animation libraries to take control.
JavaScript animation libraries such as [Motion](https://motion.dev) require control of the mounting and unmounting lifecycle of components in order for exit animations to play.

Base UI relies on [`element.getAnimations()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getAnimations) to detect if animations have finished on an element.
When using Motion, the `opacity` property lets this detection work easily, so always animating `opacity` to a new value for exit animations will work.
If it shouldn't be animated, you can use a value close to `1`, such as `opacity: 0.9999`.

### Elements removed from the DOM when closed

Most components like Popover are unmounted from the DOM when they are closed. To animate them:

- Make the component controlled with the `open` prop so `AnimatePresence` can see the state as a child
- Specify `keepMounted` on the `Portal` part
- Use the `render` prop to compose the `Popup` with `motion.div`

```jsx title="animated-popover.tsx" {12-18} "keepMounted"
function App() {
const [open, setOpen] = React.useState(false);

return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}
```

### Elements kept in the DOM when closed

The `Select` component must be kept mounted in the DOM even when closed. In this case, a
different approach is needed to animate it with Motion.

- Make the component controlled with the `open` prop
- Use the `render` prop to compose the `Popup` with `motion.div`
- Animate the properties based on the `open` state, avoiding `AnimatePresence`

```jsx title="animated-select.tsx" {12-20}
function App() {
const [open, setOpen] = React.useState(false);

return (
<Select.Root open={open} onOpenChange={setOpen}>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup
render={
<motion.div
initial={false}
animate={{
opacity: open ? 1 : 0,
scale: open ? 1 : 0.8,
}}
/>
}
>
Popup
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);
}
```

### Manual unmounting

For full control, you can manually unmount the component when it's closed once animations have finished using an `actionRef` passed to the `Root`:

```jsx title="manual-unmount.tsx" "actionRef"
function App() {
const [open, setOpen] = React.useState(false);
const actionRef = React.useRef({ unmount: () => {} });

return (
<Popover.Root open={open} onOpenChange={setOpen} action={actionRef}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
onAnimationComplete={() => {
if (!open) {
action.current.unmount();
}
}}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}
```
Loading