Skip to content

Commit

Permalink
[POC] Unmount ref
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks committed Dec 27, 2024
1 parent 2d41b5e commit aaa7df6
Show file tree
Hide file tree
Showing 12 changed files with 320 additions and 24 deletions.
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": "npm:[email protected]",
"@types/unist": "^3.0.3",
"chai": "^4.5.0",
"framer-motion": "^11.12.0",
"fs-extra": "^11.2.0",
"mdast-util-mdx-jsx": "^3.1.3",
"motion": "^11.15.0",
"prettier": "^3.4.1",
"rimraf": "^5.0.10",
"serve": "^14.2.4",
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/popover-portal.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"description": "A parent element to render the portal element into."
},
"keepMounted": {
"type": "boolean",
"type": "{ current: { unmount: func } } | bool",
"default": "false",
"description": "Whether to keep the portal mounted in the DOM while the popup is hidden."
}
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."
},
"unmountRef": {
"type": "{ current: { unmount: func } }",
"description": "A ref to manually unmount the popover."
},
"openOnHover": {
"type": "boolean",
"default": "false",
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 unmountRef = React.useRef({ unmount: () => {} });

return (
<Popover.Root open={open} onOpenChange={setOpen} unmountRef={unmountRef}>
<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) {
unmountRef.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
125 changes: 123 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,126 @@ 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.

Check warning on line 80 in docs/src/app/(public)/(content)/react/handbook/animation/page.mdx

View workflow job for this annotation

GitHub Actions / runner / vale

[vale] reported by reviewdog 🐶 [Google.Will] Avoid using 'will'. Raw Output: {"message": "[Google.Will] Avoid using 'will'.", "location": {"path": "docs/src/app/(public)/(content)/react/handbook/animation/page.mdx", "range": {"start": {"line": 80, "column": 141}}}, "severity": "WARNING"}
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" {11-17} "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" {11-19}
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 `unmountRef` passed to the `Root`:

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

return (
<Popover.Root open={open} onOpenChange={setOpen} unmountRef={unmountRef}>
<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) {
unmountRef.current.unmount();
}
}}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}
```
9 changes: 8 additions & 1 deletion packages/react/src/popover/portal/PopoverPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,14 @@ PopoverPortal.propTypes /* remove-proptypes */ = {
* Whether to keep the portal mounted in the DOM while the popup is hidden.
* @default false
*/
keepMounted: PropTypes.bool,
keepMounted: PropTypes.oneOfType([
PropTypes.shape({
current: PropTypes.shape({
unmount: PropTypes.func.isRequired,
}).isRequired,
}),
PropTypes.bool,
]),
} as any;

export { PopoverPortal };
11 changes: 10 additions & 1 deletion packages/react/src/popover/root/PopoverRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { OPEN_DELAY } from '../utils/constants';
* Documentation: [Base UI Popover](https://base-ui.com/react/components/popover)
*/
const PopoverRoot: React.FC<PopoverRoot.Props> = function PopoverRoot(props) {
const { openOnHover = false, delay, closeDelay = 0 } = props;
const { openOnHover = false, delay, closeDelay = 0, unmountRef } = props;

const delayWithDefault = delay ?? OPEN_DELAY;

Expand Down Expand Up @@ -43,6 +43,7 @@ const PopoverRoot: React.FC<PopoverRoot.Props> = function PopoverRoot(props) {
open: props.open,
onOpenChange: props.onOpenChange,
defaultOpen: props.defaultOpen,
unmountRef,
});

const contextValue: PopoverRootContext = React.useMemo(
Expand Down Expand Up @@ -153,6 +154,14 @@ PopoverRoot.propTypes /* remove-proptypes */ = {
* @default false
*/
openOnHover: PropTypes.bool,
/**
* A ref to manually unmount the popover.
*/
unmountRef: PropTypes.shape({
current: PropTypes.shape({
unmount: PropTypes.func.isRequired,
}).isRequired,
}),
} as any;

export { PopoverRoot };
17 changes: 13 additions & 4 deletions packages/react/src/popover/root/usePopoverRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,20 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
},
);

const handleUnmount = useEventCallback(() => {
setMounted(false);
setOpenReason(null);
});

useAfterExitAnimation({
enabled: !params.unmountRef,
open,
animatedElementRef: popupRef,
onFinished: () => {
setMounted(false);
setOpenReason(null);
},
onFinished: handleUnmount,
});

React.useImperativeHandle(params.unmountRef, () => ({ unmount: handleUnmount }), [handleUnmount]);

React.useEffect(() => {
return () => {
clearTimeout(clickEnabledTimeoutRef.current);
Expand Down Expand Up @@ -229,6 +234,10 @@ export namespace usePopoverRoot {
* @default 0
*/
closeDelay?: number;
/**
* A ref to manually unmount the popover.
*/
unmountRef?: React.RefObject<{ unmount: () => void }>;
}

export interface ReturnValue {
Expand Down
Loading

0 comments on commit aaa7df6

Please sign in to comment.