diff --git a/docs/package.json b/docs/package.json index 7f4042bdee..57aaa92eea 100644 --- a/docs/package.json +++ b/docs/package.json @@ -87,9 +87,9 @@ "@types/react-dom": "^19.0.2", "@types/unist": "^3.0.3", "chai": "^4.5.0", - "framer-motion": "^11.15.0", "fs-extra": "^11.2.0", "mdast-util-mdx-jsx": "^3.1.3", + "motion": "^11.15.0", "prettier": "^3.4.2", "rimraf": "^5.0.10", "serve": "^14.2.4", diff --git a/docs/reference/generated/alert-dialog-root.json b/docs/reference/generated/alert-dialog-root.json index 7b06febe0f..9d6e612551 100644 --- a/docs/reference/generated/alert-dialog-root.json +++ b/docs/reference/generated/alert-dialog-root.json @@ -14,6 +14,10 @@ "onOpenChange": { "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." } }, "dataAttributes": {}, diff --git a/docs/reference/generated/dialog-root.json b/docs/reference/generated/dialog-root.json index ae72c2198a..10bdd88567 100644 --- a/docs/reference/generated/dialog-root.json +++ b/docs/reference/generated/dialog-root.json @@ -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", diff --git a/docs/reference/generated/menu-root.json b/docs/reference/generated/menu-root.json index aee7fbf47e..ad36f0f012 100644 --- a/docs/reference/generated/menu-root.json +++ b/docs/reference/generated/menu-root.json @@ -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", diff --git a/docs/reference/generated/popover-root.json b/docs/reference/generated/popover-root.json index 6b3efcddcc..1939f52f70 100644 --- a/docs/reference/generated/popover-root.json +++ b/docs/reference/generated/popover-root.json @@ -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." + }, "openOnHover": { "type": "boolean", "default": "false", diff --git a/docs/reference/generated/preview-card-root.json b/docs/reference/generated/preview-card-root.json index e99b8789d7..b0cae6d630 100644 --- a/docs/reference/generated/preview-card-root.json +++ b/docs/reference/generated/preview-card-root.json @@ -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." + }, "delay": { "type": "number", "default": "600", diff --git a/docs/reference/generated/select-root.json b/docs/reference/generated/select-root.json index 50fb8031c1..c5224d3921 100644 --- a/docs/reference/generated/select-root.json +++ b/docs/reference/generated/select-root.json @@ -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", diff --git a/docs/reference/generated/tooltip-root.json b/docs/reference/generated/tooltip-root.json index 0e7602dee0..4193d68e2c 100644 --- a/docs/reference/generated/tooltip-root.json +++ b/docs/reference/generated/tooltip-root.json @@ -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." + }, "trackCursorAxis": { "type": "'none' | 'x' | 'y' | 'both'", "default": "'none'", diff --git a/docs/src/app/(private)/experiments/collapsible-framer.tsx b/docs/src/app/(private)/experiments/collapsible-framer.tsx index 64f032aedd..f876c569c1 100644 --- a/docs/src/app/(private)/experiments/collapsible-framer.tsx +++ b/docs/src/app/(private)/experiments/collapsible-framer.tsx @@ -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() { diff --git a/docs/src/app/(private)/experiments/motion.tsx b/docs/src/app/(private)/experiments/motion.tsx new file mode 100644 index 0000000000..3afe6e6010 --- /dev/null +++ b/docs/src/app/(private)/experiments/motion.tsx @@ -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 ( + + Trigger + + {open && ( + + + + } + > + Popup + + + + )} + + + ); +} + +function AlwaysMounted() { + const [open, setOpen] = React.useState(false); + return ( + + Trigger + + + + } + > + Popup + + + + + ); +} + +function NoOpacity() { + const [open, setOpen] = React.useState(false); + const actionRef = React.useRef({ unmount: () => {} }); + + return ( + + Trigger + + {open && ( + + + { + if (!open) { + actionRef.current.unmount(); + } + }} + /> + } + > + Popup + + + + )} + + + ); +} + +export default function Page() { + return ( +
+

Conditionally mounted

+ +

Always mounted

+ +

No opacity

+ +
+ ); +} diff --git a/docs/src/app/(private)/experiments/tooltip.tsx b/docs/src/app/(private)/experiments/tooltip.tsx index e205e70991..9c77b8d5ce 100644 --- a/docs/src/app/(private)/experiments/tooltip.tsx +++ b/docs/src/app/(private)/experiments/tooltip.tsx @@ -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 { diff --git a/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx b/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx index 792148415a..4cd251cbee 100644 --- a/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx +++ b/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx @@ -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. +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 ( + + Trigger + + {open && ( + + + + } + > + Popup + + + + )} + + + ); +} +``` + +### 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 ( + + + + + + + + } + > + Popup + + + + + ); +} +``` + +### Manual unmounting + +For full control, you can manually unmount the component when it's closed once animations have finished using an `action` passed to the `Root`: + +```jsx title="manual-unmount.tsx" "action" +function App() { + const [open, setOpen] = React.useState(false); + const actionRef = React.useRef({ unmount: () => {} }); + + return ( + + Trigger + + {open && ( + + + { + if (!open) { + action.current.unmount(); + } + }} + /> + } + > + Popup + + + + )} + + + ); +} +``` diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx index c4e0093131..a2658c7422 100644 --- a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx +++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx @@ -11,8 +11,8 @@ import { useDialogRoot } from '../../dialog/root/useDialogRoot'; * * Documentation: [Base UI Alert Dialog](https://base-ui.com/react/components/alert-dialog) */ -const AlertDialogRoot: React.FC = function AlertDialogRoot(props) { - const { children, defaultOpen = false, onOpenChange, open } = props; +const AlertDialogRoot = function AlertDialogRoot(props: AlertDialogRoot.Props) { + const { children, defaultOpen = false, onOpenChange, open, action } = props; const parentDialogRootContext = React.useContext(AlertDialogRootContext); @@ -20,6 +20,7 @@ const AlertDialogRoot: React.FC = function AlertDialogRoo open, defaultOpen, onOpenChange, + action, modal: true, dismissible: false, onNestedDialogClose: parentDialogRootContext?.onNestedDialogClose, @@ -41,7 +42,7 @@ const AlertDialogRoot: React.FC = function AlertDialogRoo }; namespace AlertDialogRoot { - export type Props = Omit; + export interface Props extends Omit {} } AlertDialogRoot.propTypes /* remove-proptypes */ = { @@ -49,6 +50,14 @@ AlertDialogRoot.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * A ref to imperative actions. + */ + action: PropTypes.shape({ + current: PropTypes.shape({ + unmount: PropTypes.func.isRequired, + }).isRequired, + }), /** * @ignore */ diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx index 3f8a0f8a3e..790c1365a2 100644 --- a/packages/react/src/dialog/root/DialogRoot.test.tsx +++ b/packages/react/src/dialog/root/DialogRoot.test.tsx @@ -610,4 +610,42 @@ describe('', () => { }); }); }); + + describe('prop: action', () => { + it('unmounts the dialog when the `unmount` method is called', async () => { + const actionRef = { + current: { + unmount: spy(), + }, + }; + + const { user } = await render( + + Open + + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + act(() => actionRef.current.unmount()); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).to.equal(null); + }); + }); + }); }); diff --git a/packages/react/src/dialog/root/DialogRoot.tsx b/packages/react/src/dialog/root/DialogRoot.tsx index 66930017c6..5b2425f150 100644 --- a/packages/react/src/dialog/root/DialogRoot.tsx +++ b/packages/react/src/dialog/root/DialogRoot.tsx @@ -19,6 +19,7 @@ const DialogRoot = function DialogRoot(props: DialogRoot.Props) { modal = true, onOpenChange, open, + action, } = props; const parentDialogRootContext = useOptionalDialogRootContext(); @@ -29,6 +30,7 @@ const DialogRoot = function DialogRoot(props: DialogRoot.Props) { onOpenChange, modal, dismissible, + action, onNestedDialogClose: parentDialogRootContext?.onNestedDialogClose, onNestedDialogOpen: parentDialogRootContext?.onNestedDialogOpen, }); @@ -58,6 +60,14 @@ DialogRoot.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * A ref to imperative actions. + */ + action: PropTypes.shape({ + current: PropTypes.shape({ + unmount: PropTypes.func.isRequired, + }).isRequired, + }), /** * @ignore */ diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts index 7d1c5240ca..c82703b96d 100644 --- a/packages/react/src/dialog/root/useDialogRoot.ts +++ b/packages/react/src/dialog/root/useDialogRoot.ts @@ -59,14 +59,19 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R }, ); + const handleUnmount = useEventCallback(() => { + setMounted(false); + }); + useAfterExitAnimation({ + enabled: !params.action, open, animatedElementRef: popupRef, - onFinished() { - setMounted(false); - }, + onFinished: handleUnmount, }); + React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]); + useScrollLock(open && modal, popupElement); const handleFloatingUIOpenChange = ( @@ -198,10 +203,15 @@ export interface SharedParameters { * @default true */ dismissible?: boolean; + /** + * A ref to imperative actions. + */ + action?: React.RefObject<{ unmount: () => void }>; } export namespace useDialogRoot { - export interface Parameters extends RequiredExcept { + export interface Parameters + extends RequiredExcept { /** * Callback to invoke when a nested dialog is opened. */ @@ -210,6 +220,10 @@ export namespace useDialogRoot { * Callback to invoke when a nested dialog is closed. */ onNestedDialogClose?: () => void; + /** + * A ref to imperative actions. + */ + action?: React.RefObject<{ unmount: () => void }>; } export interface ReturnValue { diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx index 662609a273..aa79ac3f69 100644 --- a/packages/react/src/menu/root/MenuRoot.test.tsx +++ b/packages/react/src/menu/root/MenuRoot.test.tsx @@ -876,4 +876,46 @@ describe('', () => { expect(positioner.previousElementSibling).to.equal(null); }); }); + + describe('prop: action', () => { + it('unmounts the menu when the `unmount` method is called', async () => { + const actionRef = { + current: { + unmount: spy(), + }, + }; + + await render( + + Open + + + + 1 + + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + act(() => actionRef.current.unmount()); + + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + }); + }); }); diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx index ce482cfe37..41a0aff1bc 100644 --- a/packages/react/src/menu/root/MenuRoot.tsx +++ b/packages/react/src/menu/root/MenuRoot.tsx @@ -13,7 +13,7 @@ import type { OpenChangeReason } from '../../utils/translateOpenChangeReason'; * * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ -const MenuRoot: React.FC = function MenuRoot(props) { +const MenuRoot = function MenuRoot(props: MenuRoot.Props) { const { children, defaultOpen = false, @@ -26,6 +26,7 @@ const MenuRoot: React.FC = function MenuRoot(props) { orientation = 'vertical', delay = 100, openOnHover: openOnHoverProp, + action, } = props; const direction = useDirection(); @@ -54,6 +55,7 @@ const MenuRoot: React.FC = function MenuRoot(props) { delay, onTypingChange, modal, + action, }); const context: MenuRootContext = React.useMemo( @@ -141,6 +143,10 @@ namespace MenuRoot { * Defaults to `true` for nested menus. */ openOnHover?: boolean; + /** + * A ref to imperative actions. + */ + action?: React.RefObject<{ unmount: () => void }>; } } @@ -149,6 +155,14 @@ MenuRoot.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * A ref to imperative actions. + */ + action: PropTypes.shape({ + current: PropTypes.shape({ + unmount: PropTypes.func.isRequired, + }).isRequired, + }), /** * @ignore */ diff --git a/packages/react/src/menu/root/useMenuRoot.ts b/packages/react/src/menu/root/useMenuRoot.ts index 058d927800..4df9ceafaf 100644 --- a/packages/react/src/menu/root/useMenuRoot.ts +++ b/packages/react/src/menu/root/useMenuRoot.ts @@ -89,17 +89,22 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret }, ); + const handleUnmount = useEventCallback(() => { + setMounted(false); + setOpenReason(null); + setHoverEnabled(true); + setStickIfOpen(true); + }); + useAfterExitAnimation({ + enabled: !parameters.action, open, animatedElementRef: popupRef, - onFinished() { - setMounted(false); - setOpenReason(null); - setHoverEnabled(true); - setStickIfOpen(true); - }, + onFinished: handleUnmount, }); + React.useImperativeHandle(parameters.action, () => ({ unmount: handleUnmount }), [handleUnmount]); + const clearStickIfOpenTimeout = useEventCallback(() => { clearTimeout(stickIfOpenTimeoutRef.current); }); @@ -347,6 +352,10 @@ export namespace useMenuRoot { */ onTypingChange: (typing: boolean) => void; modal: boolean; + /** + * A ref to imperative actions. + */ + action: React.RefObject<{ unmount: () => void }> | undefined; } export interface ReturnValue { diff --git a/packages/react/src/popover/root/PopoverRoot.test.tsx b/packages/react/src/popover/root/PopoverRoot.test.tsx index be770578a0..05441b9c92 100644 --- a/packages/react/src/popover/root/PopoverRoot.test.tsx +++ b/packages/react/src/popover/root/PopoverRoot.test.tsx @@ -535,4 +535,44 @@ describe('', () => { expect(lastInput).toHaveFocus(); }); }); + + describe('prop: action', () => { + it('unmounts the popover when the `unmount` method is called', async () => { + const actionRef = { + current: { + unmount: spy(), + }, + }; + + const { user } = await render( + + Open + + + Content + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + act(() => actionRef.current.unmount()); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).to.equal(null); + }); + }); + }); }); diff --git a/packages/react/src/popover/root/PopoverRoot.tsx b/packages/react/src/popover/root/PopoverRoot.tsx index 7a03624101..bf45b8c3cb 100644 --- a/packages/react/src/popover/root/PopoverRoot.tsx +++ b/packages/react/src/popover/root/PopoverRoot.tsx @@ -11,7 +11,7 @@ import { OPEN_DELAY } from '../utils/constants'; * * Documentation: [Base UI Popover](https://base-ui.com/react/components/popover) */ -const PopoverRoot: React.FC = function PopoverRoot(props) { +const PopoverRoot = function PopoverRoot(props: PopoverRoot.Props) { const { defaultOpen = false, onOpenChange, @@ -19,6 +19,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { openOnHover = false, delay, closeDelay = 0, + action, } = props; const delayWithDefault = delay ?? OPEN_DELAY; @@ -31,6 +32,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { openOnHover, delay: delayWithDefault, closeDelay, + action, }); const contextValue: PopoverRootContext = React.useMemo( @@ -51,7 +53,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { namespace PopoverRoot { export interface State {} - export interface Props extends Omit { + export interface Props extends usePopoverRoot.Parameters { children?: React.ReactNode; } } @@ -61,6 +63,14 @@ PopoverRoot.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * A ref to imperative actions. + */ + action: PropTypes.shape({ + current: PropTypes.shape({ + unmount: PropTypes.func.isRequired, + }).isRequired, + }), /** * @ignore */ diff --git a/packages/react/src/popover/root/usePopoverRoot.ts b/packages/react/src/popover/root/usePopoverRoot.ts index a7a0950ba8..ba857cdad8 100644 --- a/packages/react/src/popover/root/usePopoverRoot.ts +++ b/packages/react/src/popover/root/usePopoverRoot.ts @@ -76,15 +76,20 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo }, ); + const handleUnmount = useEventCallback(() => { + setMounted(false); + setOpenReason(null); + }); + useAfterExitAnimation({ + enabled: !params.action, open, animatedElementRef: popupRef, - onFinished() { - setMounted(false); - setOpenReason(null); - }, + onFinished: handleUnmount, }); + React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]); + React.useEffect(() => { return () => { clearTimeout(clickEnabledTimeoutRef.current); @@ -230,6 +235,10 @@ export namespace usePopoverRoot { * @default 0 */ closeDelay?: number; + /** + * A ref to imperative actions. + */ + action?: React.RefObject<{ unmount: () => void }>; } export interface ReturnValue { diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx index 70aebc605f..38c4a17f34 100644 --- a/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx +++ b/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx @@ -495,4 +495,44 @@ describe('', () => { expect(screen.queryByText('Content')).to.equal(null); }); }); + + describe('prop: action', () => { + it('unmounts the preview card when the `unmount` method is called', async () => { + const actionRef = { + current: { + unmount: spy(), + }, + }; + + const { user } = await render( + + Open + + + Content + + + , + ); + + const trigger = screen.getByRole('link'); + await user.hover(trigger); + + await waitFor(() => { + expect(screen.queryByTestId('positioner')).not.to.equal(null); + }); + + await user.unhover(trigger); + + await waitFor(() => { + expect(screen.queryByTestId('positioner')).not.to.equal(null); + }); + + act(() => actionRef.current.unmount()); + + await waitFor(() => { + expect(screen.queryByTestId('positioner')).to.equal(null); + }); + }); + }); }); diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx index 347e735f16..5eef9d24fa 100644 --- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx +++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx @@ -11,8 +11,8 @@ import { CLOSE_DELAY, OPEN_DELAY } from '../utils/constants'; * * Documentation: [Base UI Preview Card](https://base-ui.com/react/components/preview-card) */ -const PreviewCardRoot: React.FC = function PreviewCardRoot(props) { - const { delay, closeDelay } = props; +const PreviewCardRoot = function PreviewCardRoot(props: PreviewCardRoot.Props) { + const { delay, closeDelay, action } = props; const delayWithDefault = delay ?? OPEN_DELAY; const closeDelayWithDefault = closeDelay ?? CLOSE_DELAY; @@ -20,6 +20,7 @@ const PreviewCardRoot: React.FC = function PreviewCardRoo const previewCardRoot = usePreviewCardRoot({ delay, closeDelay, + action, open: props.open, onOpenChange: props.onOpenChange, defaultOpen: props.defaultOpen, @@ -54,6 +55,14 @@ PreviewCardRoot.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * A ref to imperative actions. + */ + action: PropTypes.shape({ + current: PropTypes.shape({ + unmount: PropTypes.func.isRequired, + }).isRequired, + }), /** * @ignore */ diff --git a/packages/react/src/preview-card/root/usePreviewCardRoot.ts b/packages/react/src/preview-card/root/usePreviewCardRoot.ts index a25be98968..1ff26e67fb 100644 --- a/packages/react/src/preview-card/root/usePreviewCardRoot.ts +++ b/packages/react/src/preview-card/root/usePreviewCardRoot.ts @@ -59,14 +59,19 @@ export function usePreviewCardRoot( }, ); + const handleUnmount = useEventCallback(() => { + setMounted(false); + }); + useAfterExitAnimation({ + enabled: !params.action, open, animatedElementRef: popupRef, - onFinished() { - setMounted(false); - }, + onFinished: handleUnmount, }); + React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]); + const context = useFloatingRootContext({ elements: { reference: triggerElement, floating: positionerElement }, open, @@ -172,6 +177,10 @@ export namespace usePreviewCardRoot { * @default 300 */ closeDelay?: number; + /** + * A ref to imperative actions. + */ + action?: React.RefObject<{ unmount: () => void }>; } export interface ReturnValue { diff --git a/packages/react/src/select/root/SelectRoot.test.tsx b/packages/react/src/select/root/SelectRoot.test.tsx index 98cf1c40b3..0064ff5096 100644 --- a/packages/react/src/select/root/SelectRoot.test.tsx +++ b/packages/react/src/select/root/SelectRoot.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Select } from '@base-ui-components/react/select'; -import { fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; +import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; import { createRenderer, isJSDOM } from '#test-utils'; import { expect } from 'chai'; import { spy } from 'sinon'; @@ -430,4 +430,46 @@ describe('', () => { expect(positioner.previousElementSibling).to.equal(null); }); }); + + describe('prop: action', () => { + it('unmounts the select when the `unmount` method is called', async () => { + const actionRef = { + current: { + unmount: spy(), + }, + }; + + const { user } = await render( + + Open + + + + 1 + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.to.equal(null); + }); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.to.equal(null); + }); + + act(() => actionRef.current.unmount()); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).to.equal(null); + }); + }); + }); }); diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx index 2f48d6498d..3643062399 100644 --- a/packages/react/src/select/root/SelectRoot.tsx +++ b/packages/react/src/select/root/SelectRoot.tsx @@ -29,6 +29,7 @@ const SelectRoot: SelectRoot = function SelectRoot( readOnly = false, required = false, modal = true, + action, } = props; const selectRoot = useSelectRoot({ @@ -44,6 +45,7 @@ const SelectRoot: SelectRoot = function SelectRoot( readOnly, required, modal, + action, }); const { setDirty, validityData } = useFieldRootContext(); @@ -113,6 +115,14 @@ SelectRoot.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * A ref to imperative actions. + */ + action: PropTypes.shape({ + current: PropTypes.shape({ + unmount: PropTypes.func.isRequired, + }).isRequired, + }), /** * Determines if the selected item inside the popup should align to the trigger element. * @default true @@ -178,8 +188,6 @@ SelectRoot.propTypes /* remove-proptypes */ = { value: PropTypes.any, } as any; -export { SelectRoot }; - namespace SelectRoot { export interface Props extends useSelectRoot.Parameters { children?: React.ReactNode; @@ -192,3 +200,5 @@ interface SelectRoot { (props: SelectRoot.Props): React.JSX.Element; propTypes?: any; } + +export { SelectRoot }; diff --git a/packages/react/src/select/root/useSelectRoot.ts b/packages/react/src/select/root/useSelectRoot.ts index cdada0d008..0dd6a6e386 100644 --- a/packages/react/src/select/root/useSelectRoot.ts +++ b/packages/react/src/select/root/useSelectRoot.ts @@ -124,15 +124,20 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelect } }); + const handleUnmount = useEventCallback(() => { + setMounted(false); + setActiveIndex(null); + }); + useAfterExitAnimation({ + enabled: !params.action, open, animatedElementRef: popupRef, - onFinished() { - setMounted(false); - setActiveIndex(null); - }, + onFinished: handleUnmount, }); + React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]); + const setValue = useEventCallback((nextValue: any, event?: Event) => { params.onValueChange?.(nextValue, event); setValueUnwrapped(nextValue); @@ -405,6 +410,10 @@ export namespace useSelectRoot { * @default true */ modal?: boolean; + /** + * A ref to imperative actions. + */ + action?: React.RefObject<{ unmount: () => void }>; } export interface ReturnValue { diff --git a/packages/react/src/tooltip/root/TooltipRoot.tsx b/packages/react/src/tooltip/root/TooltipRoot.tsx index dc48a0fae3..d99c5a6d6b 100644 --- a/packages/react/src/tooltip/root/TooltipRoot.tsx +++ b/packages/react/src/tooltip/root/TooltipRoot.tsx @@ -11,7 +11,7 @@ import { OPEN_DELAY } from '../utils/constants'; * * Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip) */ -const TooltipRoot: React.FC = function TooltipRoot(props) { +const TooltipRoot = function TooltipRoot(props: TooltipRoot.Props) { const { defaultOpen = false, onOpenChange, @@ -20,6 +20,7 @@ const TooltipRoot: React.FC = function TooltipRoot(props) { closeDelay, hoverable = true, trackCursorAxis = 'none', + action, } = props; const delayWithDefault = delay ?? OPEN_DELAY; @@ -34,6 +35,7 @@ const TooltipRoot: React.FC = function TooltipRoot(props) { trackCursorAxis, delay, closeDelay, + action, }); const contextValue: TooltipRootContext = React.useMemo( @@ -64,6 +66,14 @@ TooltipRoot.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * A ref to imperative actions. + */ + action: PropTypes.shape({ + current: PropTypes.shape({ + unmount: PropTypes.func.isRequired, + }).isRequired, + }), /** * @ignore */ diff --git a/packages/react/src/tooltip/root/useTooltipRoot.ts b/packages/react/src/tooltip/root/useTooltipRoot.ts index c9fbd7dc4b..ce6b9a3ecb 100644 --- a/packages/react/src/tooltip/root/useTooltipRoot.ts +++ b/packages/react/src/tooltip/root/useTooltipRoot.ts @@ -62,14 +62,19 @@ export function useTooltipRoot(params: useTooltipRoot.Parameters): useTooltipRoo const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); + const handleUnmount = useEventCallback(() => { + setMounted(false); + }); + useAfterExitAnimation({ + enabled: !params.action, open, animatedElementRef: popupRef, - onFinished() { - setMounted(false); - }, + onFinished: handleUnmount, }); + React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]); + const context = useFloatingRootContext({ elements: { reference: triggerElement, floating: positionerElement }, open, @@ -203,6 +208,10 @@ export namespace useTooltipRoot { * @default 0 */ closeDelay?: number; + /** + * A ref to imperative actions. + */ + action?: React.RefObject<{ unmount: () => void }>; } export interface ReturnValue { diff --git a/packages/react/src/utils/mergeReactProps.test.ts b/packages/react/src/utils/mergeReactProps.test.ts index fa7166711b..cba0e6c098 100644 --- a/packages/react/src/utils/mergeReactProps.test.ts +++ b/packages/react/src/utils/mergeReactProps.test.ts @@ -15,9 +15,9 @@ describe('mergeReactProps', () => { }; const mergedProps = mergeReactProps<'button'>(theirProps, ourProps); - mergedProps.onClick?.({} as any); - mergedProps.onKeyDown?.({} as any); - mergedProps.onPaste?.({} as any); + mergedProps.onClick?.({ nativeEvent: {} } as any); + mergedProps.onKeyDown?.({ nativeEvent: {} } as any); + mergedProps.onPaste?.({ nativeEvent: {} } as any); expect(theirProps.onClick.calledBefore(ourProps.onClick)).to.equal(true); expect(theirProps.onClick.callCount).to.equal(1); @@ -47,7 +47,7 @@ describe('mergeReactProps', () => { }, ); - mergedProps.onClick?.({} as any); + mergedProps.onClick?.({ nativeEvent: {} } as any); expect(log).to.deep.equal(['1', '2', '3']); }); @@ -106,7 +106,7 @@ describe('mergeReactProps', () => { }, ); - mergedProps.onClick?.({} as any); + mergedProps.onClick?.({ nativeEvent: {} } as any); expect(ran).to.equal(true); }); @@ -132,7 +132,7 @@ describe('mergeReactProps', () => { }, ); - mergedProps.onClick?.({} as any); + mergedProps.onClick?.({ nativeEvent: {} } as any); expect(ran).to.equal(false); }); @@ -159,7 +159,7 @@ describe('mergeReactProps', () => { }, ); - mergedProps.onClick?.({} as any); + mergedProps.onClick?.({ nativeEvent: {} } as any); expect(log).to.deep.equal(['0', '1']); }); diff --git a/packages/react/src/utils/mergeReactProps.ts b/packages/react/src/utils/mergeReactProps.ts index 62539d511f..758e8db112 100644 --- a/packages/react/src/utils/mergeReactProps.ts +++ b/packages/react/src/utils/mergeReactProps.ts @@ -49,9 +49,12 @@ function merge( const baseUIEvent = event as BaseUIEvent; - baseUIEvent.preventBaseUIHandler = () => { - isPrevented = true; - }; + // The event is a real React event, not e.g. a `motion` event + if (baseUIEvent.nativeEvent) { + baseUIEvent.preventBaseUIHandler = () => { + isPrevented = true; + }; + } const result = theirHandler(baseUIEvent); diff --git a/packages/react/src/utils/useAfterExitAnimation.tsx b/packages/react/src/utils/useAfterExitAnimation.tsx index 4ad5f1f3e8..5843965fd0 100644 --- a/packages/react/src/utils/useAfterExitAnimation.tsx +++ b/packages/react/src/utils/useAfterExitAnimation.tsx @@ -1,5 +1,5 @@ +import * as React from 'react'; import { useAnimationsFinished } from './useAnimationsFinished'; -import { useEnhancedEffect } from './useEnhancedEffect'; import { useEventCallback } from './useEventCallback'; import { useLatestRef } from './useLatestRef'; @@ -8,13 +8,17 @@ import { useLatestRef } from './useLatestRef'; * Useful for unmounting the component after animating out. */ export function useAfterExitAnimation(parameters: useAfterExitAnimation.Parameters) { - const { open, animatedElementRef, onFinished: onFinishedParam } = parameters; + const { enabled = true, open, animatedElementRef, onFinished: onFinishedParam } = parameters; const onFinished = useEventCallback(onFinishedParam); const runOnceAnimationsFinish = useAnimationsFinished(animatedElementRef); const openRef = useLatestRef(open); - useEnhancedEffect(() => { + React.useEffect(() => { + if (!enabled) { + return; + } + function callOnFinished() { if (!openRef.current) { onFinished(); @@ -24,11 +28,12 @@ export function useAfterExitAnimation(parameters: useAfterExitAnimation.Paramete if (!open) { runOnceAnimationsFinish(callOnFinished); } - }, [open, openRef, runOnceAnimationsFinish, onFinished]); + }, [enabled, open, openRef, runOnceAnimationsFinish, onFinished]); } export namespace useAfterExitAnimation { export interface Parameters { + enabled?: boolean; /** * Determines if the component is open. * The logic runs when the component goes from open to closed. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a55980646..420ed8b84c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,15 +540,15 @@ importers: chai: specifier: ^4.5.0 version: 4.5.0 - framer-motion: - specifier: ^11.15.0 - version: 11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) fs-extra: specifier: ^11.2.0 version: 11.2.0 mdast-util-mdx-jsx: specifier: ^3.1.3 version: 3.1.3 + motion: + specifier: ^11.15.0 + version: 11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) prettier: specifier: ^3.4.2 version: 3.4.2 @@ -6765,6 +6765,20 @@ packages: motion-utils@11.14.3: resolution: {integrity: sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==} + motion@11.15.0: + resolution: {integrity: sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -16484,6 +16498,15 @@ snapshots: motion-utils@11.14.3: {} + motion@11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + framer-motion: 11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.3.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + mri@1.2.0: {} mrmime@2.0.0: {}