diff --git a/docs/package.json b/docs/package.json index 62910c3523..ab0e070da2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -87,9 +87,9 @@ "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", "@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", diff --git a/docs/reference/generated/alert-dialog-backdrop.json b/docs/reference/generated/alert-dialog-backdrop.json index 0f6c03789b..51d2ad0a79 100644 --- a/docs/reference/generated/alert-dialog-backdrop.json +++ b/docs/reference/generated/alert-dialog-backdrop.json @@ -6,11 +6,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the element in the DOM while the alert dialog is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/alert-dialog-popup.json b/docs/reference/generated/alert-dialog-popup.json index 4abf067b82..b8a174b76f 100644 --- a/docs/reference/generated/alert-dialog-popup.json +++ b/docs/reference/generated/alert-dialog-popup.json @@ -14,11 +14,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the element in the DOM while the alert dialog is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/alert-dialog-portal.json b/docs/reference/generated/alert-dialog-portal.json new file mode 100644 index 0000000000..0422346f24 --- /dev/null +++ b/docs/reference/generated/alert-dialog-portal.json @@ -0,0 +1,17 @@ +{ + "name": "AlertDialogPortal", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "props": { + "container": { + "type": "React.Ref | HTMLElement | null", + "description": "A parent element to render the portal element into." + }, + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/dialog-backdrop.json b/docs/reference/generated/dialog-backdrop.json index 801ca1fab0..db2b4ba5b7 100644 --- a/docs/reference/generated/dialog-backdrop.json +++ b/docs/reference/generated/dialog-backdrop.json @@ -6,11 +6,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the dialog is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/dialog-popup.json b/docs/reference/generated/dialog-popup.json index 1a0128d58f..186de3d19c 100644 --- a/docs/reference/generated/dialog-popup.json +++ b/docs/reference/generated/dialog-popup.json @@ -14,11 +14,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the dialog is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/dialog-portal.json b/docs/reference/generated/dialog-portal.json new file mode 100644 index 0000000000..87565ef214 --- /dev/null +++ b/docs/reference/generated/dialog-portal.json @@ -0,0 +1,17 @@ +{ + "name": "DialogPortal", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "props": { + "container": { + "type": "React.Ref | HTMLElement | null", + "description": "A parent element to render the portal element into." + }, + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/menu-backdrop.json b/docs/reference/generated/menu-backdrop.json index 58832ed4bc..de7938fded 100644 --- a/docs/reference/generated/menu-backdrop.json +++ b/docs/reference/generated/menu-backdrop.json @@ -6,11 +6,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the menu is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/portal.json b/docs/reference/generated/menu-portal.json similarity index 82% rename from docs/reference/generated/portal.json rename to docs/reference/generated/menu-portal.json index b0e0ba31d5..5a7bcc3858 100644 --- a/docs/reference/generated/portal.json +++ b/docs/reference/generated/menu-portal.json @@ -1,10 +1,10 @@ { - "name": "Portal", + "name": "MenuPortal", "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", "props": { "container": { "type": "React.Ref | HTMLElement | null", - "description": "A parent element to render the portal into." + "description": "A parent element to render the portal element into." }, "keepMounted": { "type": "boolean", diff --git a/docs/reference/generated/menu-positioner.json b/docs/reference/generated/menu-positioner.json index dc8fc9f0ac..105a5f811a 100644 --- a/docs/reference/generated/menu-positioner.json +++ b/docs/reference/generated/menu-positioner.json @@ -53,11 +53,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the menu is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/popover-backdrop.json b/docs/reference/generated/popover-backdrop.json index d3e69417e7..4b81ad5446 100644 --- a/docs/reference/generated/popover-backdrop.json +++ b/docs/reference/generated/popover-backdrop.json @@ -6,11 +6,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the popover is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/popover-portal.json b/docs/reference/generated/popover-portal.json new file mode 100644 index 0000000000..4a4554c421 --- /dev/null +++ b/docs/reference/generated/popover-portal.json @@ -0,0 +1,17 @@ +{ + "name": "PopoverPortal", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "props": { + "container": { + "type": "React.Ref | HTMLElement | null", + "description": "A parent element to render the portal element into." + }, + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/popover-positioner.json b/docs/reference/generated/popover-positioner.json index 9a155f18dd..5d7d915bcc 100644 --- a/docs/reference/generated/popover-positioner.json +++ b/docs/reference/generated/popover-positioner.json @@ -55,11 +55,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the popover is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/popover-root.json b/docs/reference/generated/popover-root.json index 6b3efcddcc..6a14df1a02 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." }, + "unmountRef": { + "type": "{ current: { unmount: func } }", + "description": "A ref to manually unmount the popover." + }, "openOnHover": { "type": "boolean", "default": "false", diff --git a/docs/reference/generated/preview-card-backdrop.json b/docs/reference/generated/preview-card-backdrop.json index 667211ad56..815bae8001 100644 --- a/docs/reference/generated/preview-card-backdrop.json +++ b/docs/reference/generated/preview-card-backdrop.json @@ -6,11 +6,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the preview card is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/preview-card-portal.json b/docs/reference/generated/preview-card-portal.json new file mode 100644 index 0000000000..34c82e1b85 --- /dev/null +++ b/docs/reference/generated/preview-card-portal.json @@ -0,0 +1,17 @@ +{ + "name": "PreviewCardPortal", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "props": { + "container": { + "type": "React.Ref | HTMLElement | null", + "description": "A parent element to render the portal element into." + }, + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/preview-card-positioner.json b/docs/reference/generated/preview-card-positioner.json index 5fea11a83e..8ec325c32e 100644 --- a/docs/reference/generated/preview-card-positioner.json +++ b/docs/reference/generated/preview-card-positioner.json @@ -55,11 +55,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the preview card is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/select-backdrop.json b/docs/reference/generated/select-backdrop.json index bd55c2bf52..2dab04eafd 100644 --- a/docs/reference/generated/select-backdrop.json +++ b/docs/reference/generated/select-backdrop.json @@ -6,11 +6,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the select menu is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/reference/generated/select-portal.json b/docs/reference/generated/select-portal.json index d568bb6286..72a3729bc6 100644 --- a/docs/reference/generated/select-portal.json +++ b/docs/reference/generated/select-portal.json @@ -4,7 +4,7 @@ "props": { "container": { "type": "React.Ref | HTMLElement | null", - "description": "A parent element to render the portal into." + "description": "A parent element to render the portal element into." } }, "dataAttributes": {}, diff --git a/docs/reference/generated/tooltip-portal.json b/docs/reference/generated/tooltip-portal.json new file mode 100644 index 0000000000..41be076137 --- /dev/null +++ b/docs/reference/generated/tooltip-portal.json @@ -0,0 +1,17 @@ +{ + "name": "TooltipPortal", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "props": { + "container": { + "type": "React.Ref | HTMLElement | null", + "description": "A parent element to render the portal element into." + }, + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/tooltip-positioner.json b/docs/reference/generated/tooltip-positioner.json index 538059bb6a..5ca373df6f 100644 --- a/docs/reference/generated/tooltip-positioner.json +++ b/docs/reference/generated/tooltip-positioner.json @@ -55,11 +55,6 @@ "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, - "keepMounted": { - "type": "boolean", - "default": "false", - "description": "Whether to keep the HTML element in the DOM while the tooltip is hidden." - }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." diff --git a/docs/src/app/(private)/experiments/anchor-positioning.tsx b/docs/src/app/(private)/experiments/anchor-positioning.tsx index 8c3a4107a3..bad594ce66 100644 --- a/docs/src/app/(private)/experiments/anchor-positioning.tsx +++ b/docs/src/app/(private)/experiments/anchor-positioning.tsx @@ -49,6 +49,7 @@ export default function AnchorPositioning() { arrowPadding, trackAnchor, mounted: true, + keepMounted: true, }); const handleInitialScroll = React.useCallback((node: HTMLDivElement | null) => { diff --git a/docs/src/app/(private)/experiments/anchor-side-animations.tsx b/docs/src/app/(private)/experiments/anchor-side-animations.tsx index eecc703447..678d44e0c1 100644 --- a/docs/src/app/(private)/experiments/anchor-side-animations.tsx +++ b/docs/src/app/(private)/experiments/anchor-side-animations.tsx @@ -13,16 +13,20 @@ export default function AnchorSideAnimations() {

transition - - - + + + + + animation - - - + + + + + ); 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/dialog.tsx b/docs/src/app/(private)/experiments/dialog.tsx index aef9c97950..1573d4d1c7 100644 --- a/docs/src/app/(private)/experiments/dialog.tsx +++ b/docs/src/app/(private)/experiments/dialog.tsx @@ -46,15 +46,17 @@ function renderContent( Open nested - - {renderContent( - `Nested dialog ${NESTED_DIALOGS + 1 - includeNested}`, - includeNested - 1, - nestedClassName, - modal, - dismissible, - )} - + + + {renderContent( + `Nested dialog ${NESTED_DIALOGS + 1 - includeNested}`, + includeNested - 1, + nestedClassName, + modal, + dismissible, + )} + + ) : null} @@ -72,23 +74,20 @@ function CssTransitionDialogDemo({ keepMounted, modal, dismissible }: DemoProps) Open with CSS transition - - - - {renderContent( - 'Dialog with CSS transitions', - NESTED_DIALOGS, - classes.withTransitions, - modal, - dismissible, - )} - + + + + {renderContent( + 'Dialog with CSS transitions', + NESTED_DIALOGS, + classes.withTransitions, + modal, + dismissible, + )} + + ); @@ -102,23 +101,20 @@ function CssAnimationDialogDemo({ keepMounted, modal, dismissible }: DemoProps) Open with CSS animation - - - - {renderContent( - 'Dialog with CSS animations', - NESTED_DIALOGS, - classes.withAnimations, - modal, - dismissible, - )} - + + + + {renderContent( + 'Dialog with CSS animations', + NESTED_DIALOGS, + classes.withAnimations, + modal, + dismissible, + )} + + ); @@ -141,18 +137,19 @@ function ReactSpringDialogDemo({ keepMounted, modal, dismissible }: DemoProps) { /> - - {renderContent( - 'Dialog with ReactSpring transitions', - 3, - classes.withReactSpringTransition, - modal, - dismissible, - )} - + + + {renderContent( + 'Dialog with ReactSpring transitions', + 3, + classes.withReactSpringTransition, + modal, + dismissible, + )} + + diff --git a/docs/src/app/(private)/experiments/menu-anchor-el.tsx b/docs/src/app/(private)/experiments/menu-anchor-el.tsx index 01bf4b83fe..39479cb555 100644 --- a/docs/src/app/(private)/experiments/menu-anchor-el.tsx +++ b/docs/src/app/(private)/experiments/menu-anchor-el.tsx @@ -14,21 +14,23 @@ export default function Page() {

Element passed to anchor

Trigger - - - - One - - - Two - - - + + + + + One + + + Two + + + +
Ref passed to anchor Trigger - - - - One - - - Two - - - + + + + + One + + + Two + + + +
Text color - - - - Black - - - Dark grey - - - Accent - - - + + + + + Black + + + Dark grey + + + Accent + + + + Style - - - - Heading - - - + + + + Heading + + - Level 1 - - + + Level 1 + + + Level 2 + + + Level 3 + + + + + + + Paragraph + + + List + + - Level 2 - - - Level 3 - - - - - - Paragraph - - - List - - - - Ordered - - - Unordered - - - - - - + + + Ordered + + + Unordered + + + + + + + + diff --git a/docs/src/app/(private)/experiments/menu-rtl.tsx b/docs/src/app/(private)/experiments/menu-rtl.tsx index 1aee9f2a55..d1c2b1e2a3 100644 --- a/docs/src/app/(private)/experiments/menu-rtl.tsx +++ b/docs/src/app/(private)/experiments/menu-rtl.tsx @@ -16,44 +16,38 @@ export default function RtlPopover() { - - - - - - Notifications - - You are all caught up. Good job! - - - + + + + + + + Notifications + + You are all caught up. Good job! + + + + - - - - - - Notifications - - You are all caught up. Good job! - - - + + + + + + + Notifications + + You are all caught up. Good job! + + + +
@@ -62,52 +56,56 @@ export default function RtlPopover() { Song - - - - - - Add to Library - Add to Playlist - - Play Next - Play Last - - Favorite - Share - - + + + + + + + Add to Library + Add to Playlist + + Play Next + Play Last + + Favorite + Share + + + Song - - - - - - Add to Library - Add to Playlist - - Play Next - Play Last - - Favorite - Share - - + + + + + + + Add to Library + Add to Playlist + + Play Next + Play Last + + Favorite + Share + + +
diff --git a/docs/src/app/(private)/experiments/modality.tsx b/docs/src/app/(private)/experiments/modality.tsx index 2ecb578fcc..2a528b609c 100644 --- a/docs/src/app/(private)/experiments/modality.tsx +++ b/docs/src/app/(private)/experiments/modality.tsx @@ -41,22 +41,24 @@ function SelectDemo({ modal, withBackdrop }: Props) { {withBackdrop && } />} - }> - - - } /> - System font - - - } /> - Arial - - - } /> - Roboto - - - + + }> + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + ); } @@ -68,11 +70,15 @@ function MenuDemo({ modal, withBackdrop }: Props) { {withBackdrop && } />} - }> - - console.log('Log out clicked')}>Log out - - + + }> + + console.log('Log out clicked')}> + Log out + + + + ); } @@ -84,16 +90,18 @@ function DialogDemo({ modal, withBackdrop }: Props) { {withBackdrop && } />} - - Subscribe - - Enter your email address to subscribe to our newsletter. - - - Subscribe - Cancel - - + + + Subscribe + + Enter your email address to subscribe to our newsletter. + + + Subscribe + Cancel + + + ); } diff --git a/docs/src/app/(private)/experiments/motion.tsx b/docs/src/app/(private)/experiments/motion.tsx new file mode 100644 index 0000000000..dcabcbed32 --- /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 unmountRef = React.useRef({ unmount: () => {} }); + + return ( + + Trigger + + {open && ( + + + { + if (!open) { + unmountRef.current.unmount(); + } + }} + /> + } + > + Popup + + + + )} + + + ); +} + +export default function Page() { + return ( +
+

Conditionally mounted

+ +

Always mounted

+ +

No opacity

+ +
+ ); +} diff --git a/docs/src/app/(private)/experiments/popup-transform-origin.tsx b/docs/src/app/(private)/experiments/popup-transform-origin.tsx index 98b804e5d2..97731bc862 100644 --- a/docs/src/app/(private)/experiments/popup-transform-origin.tsx +++ b/docs/src/app/(private)/experiments/popup-transform-origin.tsx @@ -9,9 +9,11 @@ function Popover({ side }: { side: Side }) { {side} - - - + + + + + ); } @@ -22,11 +24,13 @@ function PopoverWithArrow({ side }: { side: Side }) { {side} - - - - - + + + + + + + ); } diff --git a/docs/src/app/(private)/experiments/popups-in-popups.tsx b/docs/src/app/(private)/experiments/popups-in-popups.tsx index f8b7aad6d6..1d4383da86 100644 --- a/docs/src/app/(private)/experiments/popups-in-popups.tsx +++ b/docs/src/app/(private)/experiments/popups-in-popups.tsx @@ -108,89 +108,105 @@ function MenuDemo({ modal }: Props) { Text color - } - > - - - Black - - - Dark grey - - - Accent - - - + + } + > + + + Black + + + Dark grey + + + Accent + + + + Style - } - > - - - Heading - } - > - - - Level 1 - - - Level 2 - - - Level 3 - - - - - - Paragraph - - - List - } - > - - + } + > + + + Heading + + } > - Ordered - - + + Level 1 + + + Level 2 + + + Level 3 + + + + + + + Paragraph + + + List + + } > - Unordered - - - - - - + + + Ordered + + + Unordered + + + + + + + + diff --git a/docs/src/app/(private)/experiments/rtl.tsx b/docs/src/app/(private)/experiments/rtl.tsx index e89c421e04..91f9c1c41e 100644 --- a/docs/src/app/(private)/experiments/rtl.tsx +++ b/docs/src/app/(private)/experiments/rtl.tsx @@ -20,181 +20,205 @@ export default function RtlNestedMenu() { Menu.Trigger - - - - - Text color - - - - - Black - - - Dark grey - - + + + + + Text color + + + - Accent - - - - + + + Black + + + Dark grey + + + Accent + + + + + - - - Style - - - - - - Heading - - - - - Level 1 - - - Level 2 - - - Level 3 - - - - - + + Style + + + - Paragraph - - - - List - - - - - Ordered - - - Unordered - - - - - - - + + + + Heading + + + + + + Level 1 + + + Level 2 + + + Level 3 + + + + + + + Paragraph + + + + List + + + + + + Ordered + + + Unordered + + + + + + + + + - - Clear formatting - - - + + Clear formatting + + + + PreviewCard.Trigger - - - Base UI Logo -

Base UI

-

- Unstyled React components and hooks (@base-ui-components/react), by - @MUI_hq. -

-
- - 1 Following - - - 1,000 Followers - -
- -
-
+ + + + Base UI Logo +

Base UI

+

+ Unstyled React components and hooks (@base-ui-components/react), by + @MUI_hq. +

+
+ + 1 Following + + + 1,000 Followers + +
+ +
+
+
Popover.Trigger - - - Popover Title - Popover Description - - - + + + + Popover Title + Popover Description + + + +
diff --git a/docs/src/app/(private)/experiments/select-perf.tsx b/docs/src/app/(private)/experiments/select-perf.tsx index 09690d790c..72918ac55e 100644 --- a/docs/src/app/(private)/experiments/select-perf.tsx +++ b/docs/src/app/(private)/experiments/select-perf.tsx @@ -30,38 +30,40 @@ function BaseSelectExample() { > - - - - {items.map((item) => ( - - {item} - - ))} - - - + + + + + {items.map((item) => ( + + {item} + + ))} + + + + ); } diff --git a/docs/src/app/(private)/experiments/tooltip.tsx b/docs/src/app/(private)/experiments/tooltip.tsx index 7cd52e4a60..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 { @@ -142,24 +142,30 @@ export default function TooltipTransitionExperiment() { Anchor - - Tooltip - + + + Tooltip + + Anchor - - Tooltip - + + + Tooltip + +

CSS Animation

Anchor - - Tooltip - + + + Tooltip + +

CSS Transition Group

@@ -167,24 +173,30 @@ export default function TooltipTransitionExperiment() { Anchor - - Tooltip - + + + Tooltip + + Anchor - - Tooltip - + + + Tooltip + +

CSS Transition

Anchor - - Tooltip - + + + Tooltip + +

CSS Transition with `@starting-style`

@@ -196,11 +208,13 @@ export default function TooltipTransitionExperiment() {

Anchor - - - Tooltip - - + + + + Tooltip + + +
@@ -212,28 +226,36 @@ export default function TooltipTransitionExperiment() { Anchor - - - Tooltip - - + + + + Tooltip + + + Anchor - - - Tooltip - - + + + + Tooltip + + +

CSS Animation

Anchor - - Tooltip - + + + + Tooltip + + +

CSS Transition Group

@@ -241,30 +263,36 @@ export default function TooltipTransitionExperiment() { Anchor - - - Tooltip - - + + + + Tooltip + + + Anchor - - - Tooltip - - + + + + Tooltip + + +

CSS Transition

Anchor - - - Tooltip - - + + + + Tooltip + + +
@@ -282,20 +310,22 @@ function FramerMotion() { Anchor {isOpen && ( - - - } - > - Tooltip - - + + + + } + > + Tooltip + + + )} diff --git a/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx index e58cd5fced..64253fda19 100644 --- a/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx @@ -6,7 +6,7 @@ export default function ExampleDialog() { return ( View notifications - + Notifications 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..f61a979ab5 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 `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 ( + + Trigger + + {open && ( + + + { + if (!open) { + unmountRef.current.unmount(); + } + }} + /> + } + > + Popup + + + + )} + + + ); +} +``` diff --git a/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx b/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx index edd8d28559..c7c0b7a762 100644 --- a/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx +++ b/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx @@ -24,7 +24,7 @@ const AlertDialogBackdrop = React.forwardRef(function AlertDialogBackdrop( props: AlertDialogBackdrop.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, keepMounted = false, ...other } = props; + const { render, className, ...other } = props; const { open, nested, mounted, transitionStatus } = useAlertDialogRootContext(); const state: AlertDialogBackdrop.State = React.useMemo( @@ -45,7 +45,7 @@ const AlertDialogBackdrop = React.forwardRef(function AlertDialogBackdrop( }); // no need to render nested backdrops - const shouldRender = (keepMounted || mounted) && !nested; + const shouldRender = !nested; if (!shouldRender) { return null; } @@ -54,13 +54,7 @@ const AlertDialogBackdrop = React.forwardRef(function AlertDialogBackdrop( }); namespace AlertDialogBackdrop { - export interface Props extends BaseUIComponentProps<'div', State> { - /** - * Whether to keep the element in the DOM while the alert dialog is hidden. - * @default false - */ - keepMounted?: boolean; - } + export interface Props extends BaseUIComponentProps<'div', State> {} export interface State { /** @@ -85,11 +79,6 @@ AlertDialogBackdrop.propTypes /* remove-proptypes */ = { * returns a class based on the component’s state. */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), - /** - * Whether to keep the element in the DOM while the alert dialog is hidden. - * @default false - */ - keepMounted: PropTypes.bool, /** * Allows you to replace the component’s HTML element * with a different tag, or compose it with another component. diff --git a/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx b/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx index c9498fad98..1158b16ccc 100644 --- a/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx +++ b/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx @@ -11,7 +11,9 @@ describe('', () => { return render( - {node} + + {node} + , ); }, diff --git a/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx b/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx index be892fdfce..5b40b3718a 100644 --- a/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx +++ b/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx @@ -11,7 +11,9 @@ describe('', () => { return render( - {node} + + {node} + , ); }, diff --git a/packages/react/src/alert-dialog/index.parts.ts b/packages/react/src/alert-dialog/index.parts.ts index 87eb08a1e1..5eb6454af9 100644 --- a/packages/react/src/alert-dialog/index.parts.ts +++ b/packages/react/src/alert-dialog/index.parts.ts @@ -2,7 +2,7 @@ export { AlertDialogBackdrop as Backdrop } from './backdrop/AlertDialogBackdrop' export { AlertDialogClose as Close } from './close/AlertDialogClose'; export { AlertDialogDescription as Description } from './description/AlertDialogDescription'; export { AlertDialogPopup as Popup } from './popup/AlertDialogPopup'; -export { Portal } from '../portal/Portal'; +export { AlertDialogPortal as Portal } from './portal/AlertDialogPortal'; export { AlertDialogRoot as Root } from './root/AlertDialogRoot'; export { AlertDialogTitle as Title } from './title/AlertDialogTitle'; export { AlertDialogTrigger as Trigger } from './trigger/AlertDialogTrigger'; diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx index 241adfc903..42aa1f6acf 100644 --- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx +++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx @@ -13,8 +13,10 @@ describe('', () => { render: (node) => { return render( - - {node} + + + {node} + , ); }, @@ -24,7 +26,9 @@ describe('', () => { const { getByTestId } = await render( - + + + , ); @@ -40,10 +44,12 @@ describe('', () => { Open - - - - + + + + + + , @@ -69,12 +75,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -106,12 +114,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -140,9 +150,11 @@ describe('', () => { Open - - Close - + + + Close + + , @@ -168,9 +180,11 @@ describe('', () => { Open - - Close - + + + Close + + diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx index 785a99da62..989652fc9b 100644 --- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx +++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx @@ -15,6 +15,7 @@ import { InteractionType } from '../../utils/useEnhancedClickHandler'; import { transitionStatusMapping } from '../../utils/styleHookMapping'; import { AlertDialogPopupDataAttributes } from './AlertDialogPopupDataAttributes'; import { InternalBackdrop } from '../../utils/InternalBackdrop'; +import { useAlertDialogPortalContext } from '../portal/AlertDialogPortalContext'; const customStyleHookMapping: CustomStyleHookMapping = { ...baseMapping, @@ -34,7 +35,7 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( props: AlertDialogPopup.Props, forwardedRef: React.ForwardedRef, ) { - const { className, id, keepMounted = false, render, initialFocus, finalFocus, ...other } = props; + const { className, id, render, initialFocus, finalFocus, ...other } = props; const { descriptionElementId, @@ -54,6 +55,8 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( modal, } = useAlertDialogRootContext(); + useAlertDialogPortalContext(); + const mergedRef = useForkRef(forwardedRef, popupRef); const { getRootProps, floatingContext, resolvedInitialFocus } = useDialogPopup({ @@ -98,10 +101,6 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( customStyleHookMapping, }); - if (!keepMounted && !mounted) { - return null; - } - return ( {mounted && modal && } @@ -121,11 +120,6 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( namespace AlertDialogPopup { export interface Props extends BaseUIComponentProps<'div', State> { - /** - * Whether to keep the element in the DOM while the alert dialog is hidden. - * @default false - */ - keepMounted?: boolean; /** * Determines the element to focus when the dialog is opened. * By default, the first focusable element is focused. @@ -188,11 +182,6 @@ AlertDialogPopup.propTypes /* remove-proptypes */ = { PropTypes.func, refType, ]), - /** - * Whether to keep the element in the DOM while the alert dialog is hidden. - * @default false - */ - keepMounted: PropTypes.bool, /** * Allows you to replace the component’s HTML element * with a different tag, or compose it with another component. diff --git a/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx new file mode 100644 index 0000000000..a9443cde41 --- /dev/null +++ b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx @@ -0,0 +1,67 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingPortal } from '@floating-ui/react'; +import { useAlertDialogRootContext } from '../root/AlertDialogRootContext'; +import { AlertDialogPortalContext } from './AlertDialogPortalContext'; +import { HTMLElementType, refType } from '../../utils/proptypes'; + +/** + * A portal element that moves the popup to a different part of the DOM. + * By default, the portal element is appended to ``. + * + * Documentation: [Base UI Alert Dialog](https://base-ui.com/react/components/alert-dialog) + */ +function AlertDialogPortal(props: AlertDialogPortal.Props) { + const { children, keepMounted = false, container } = props; + + const { mounted } = useAlertDialogRootContext(); + + const shouldRender = mounted || keepMounted; + if (!shouldRender) { + return null; + } + + return ( + + {children} + + ); +} + +namespace AlertDialogPortal { + export interface Props { + children?: React.ReactNode; + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted?: boolean; + /** + * A parent element to render the portal element into. + */ + container?: HTMLElement | null | React.RefObject; + } +} + +AlertDialogPortal.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, + /** + * A parent element to render the portal element into. + */ + container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]), + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted: PropTypes.bool, +} as any; + +export { AlertDialogPortal }; diff --git a/packages/react/src/alert-dialog/portal/AlertDialogPortalContext.ts b/packages/react/src/alert-dialog/portal/AlertDialogPortalContext.ts new file mode 100644 index 0000000000..e9169df78e --- /dev/null +++ b/packages/react/src/alert-dialog/portal/AlertDialogPortalContext.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export const AlertDialogPortalContext = React.createContext(undefined); + +export function useAlertDialogPortalContext() { + const value = React.useContext(AlertDialogPortalContext); + if (value === undefined) { + throw new Error('Base UI: is missing.'); + } + return value; +} diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx index 38c643668a..d7e4994535 100644 --- a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx +++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx @@ -17,9 +17,11 @@ describe('', () => { const { user } = await render( Open - - Close - + + + Close + + , ); @@ -44,9 +46,11 @@ describe('', () => { const { user } = await render( Open - - Close - + + + Close + + , ); @@ -69,9 +73,11 @@ describe('', () => { const { user } = await render( Open - - Close - + + + Close + + , ); @@ -91,9 +97,11 @@ describe('', () => { Open Dialog - - Close Dialog - + + + Close Dialog + + diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx index 443757bc47..c4e0093131 100644 --- a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx +++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import type { DialogRoot } from '../../dialog/root/DialogRoot'; import { AlertDialogRootContext } from './AlertDialogRootContext'; import { useDialogRoot } from '../../dialog/root/useDialogRoot'; -import { PortalContext } from '../../portal/PortalContext'; /** * Groups all parts of the alert dialog. @@ -36,7 +35,7 @@ const AlertDialogRoot: React.FC = function AlertDialogRoo return ( - {children} + {children} ); }; diff --git a/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx b/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx index b296255c70..538d75c32c 100644 --- a/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx +++ b/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx @@ -11,7 +11,9 @@ describe('', () => { return render( - {node} + + {node} + , ); }, diff --git a/packages/react/src/dialog/backdrop/DialogBackdrop.tsx b/packages/react/src/dialog/backdrop/DialogBackdrop.tsx index b2dae7eba6..c224c729d2 100644 --- a/packages/react/src/dialog/backdrop/DialogBackdrop.tsx +++ b/packages/react/src/dialog/backdrop/DialogBackdrop.tsx @@ -24,7 +24,7 @@ const DialogBackdrop = React.forwardRef(function DialogBackdrop( props: DialogBackdrop.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, keepMounted = false, ...other } = props; + const { render, className, ...other } = props; const { open, nested, mounted, transitionStatus } = useDialogRootContext(); const state: DialogBackdrop.State = React.useMemo( @@ -45,7 +45,7 @@ const DialogBackdrop = React.forwardRef(function DialogBackdrop( }); // no need to render nested backdrops - const shouldRender = (keepMounted || mounted) && !nested; + const shouldRender = !nested; if (!shouldRender) { return null; } @@ -54,13 +54,7 @@ const DialogBackdrop = React.forwardRef(function DialogBackdrop( }); namespace DialogBackdrop { - export interface Props extends BaseUIComponentProps<'div', State> { - /** - * Whether to keep the HTML element in the DOM while the dialog is hidden. - * @default false - */ - keepMounted?: boolean; - } + export interface Props extends BaseUIComponentProps<'div', State> {} export interface State { /** @@ -85,11 +79,6 @@ DialogBackdrop.propTypes /* remove-proptypes */ = { * returns a class based on the component’s state. */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), - /** - * Whether to keep the HTML element in the DOM while the dialog is hidden. - * @default false - */ - keepMounted: PropTypes.bool, /** * Allows you to replace the component’s HTML element * with a different tag, or compose it with another component. diff --git a/packages/react/src/dialog/close/DialogClose.test.tsx b/packages/react/src/dialog/close/DialogClose.test.tsx index fb0cd7412a..f9a05b20c0 100644 --- a/packages/react/src/dialog/close/DialogClose.test.tsx +++ b/packages/react/src/dialog/close/DialogClose.test.tsx @@ -10,7 +10,9 @@ describe('', () => { render: (node) => { return render( - {node} + + {node} + , ); }, diff --git a/packages/react/src/dialog/description/DialogDescription.test.tsx b/packages/react/src/dialog/description/DialogDescription.test.tsx index 546794e6b1..2eac5f6b93 100644 --- a/packages/react/src/dialog/description/DialogDescription.test.tsx +++ b/packages/react/src/dialog/description/DialogDescription.test.tsx @@ -10,7 +10,9 @@ describe('', () => { render: (node) => { return render( - {node} + + {node} + , ); }, diff --git a/packages/react/src/dialog/index.parts.ts b/packages/react/src/dialog/index.parts.ts index d6df8f438f..c6b2a98d6f 100644 --- a/packages/react/src/dialog/index.parts.ts +++ b/packages/react/src/dialog/index.parts.ts @@ -2,7 +2,7 @@ export { DialogBackdrop as Backdrop } from './backdrop/DialogBackdrop'; export { DialogClose as Close } from './close/DialogClose'; export { DialogDescription as Description } from './description/DialogDescription'; export { DialogPopup as Popup } from './popup/DialogPopup'; -export { Portal } from '../portal/Portal'; +export { DialogPortal as Portal } from './portal/DialogPortal'; export { DialogRoot as Root } from './root/DialogRoot'; export { DialogTitle as Title } from './title/DialogTitle'; export { DialogTrigger as Trigger } from './trigger/DialogTrigger'; diff --git a/packages/react/src/dialog/popup/DialogPopup.test.tsx b/packages/react/src/dialog/popup/DialogPopup.test.tsx index 4c6ae9a404..8378715c91 100644 --- a/packages/react/src/dialog/popup/DialogPopup.test.tsx +++ b/packages/react/src/dialog/popup/DialogPopup.test.tsx @@ -13,7 +13,7 @@ describe('', () => { render: (node) => { return render( - {node} + {node} , ); }, @@ -28,7 +28,9 @@ describe('', () => { it(`should ${!expectedIsMounted ? 'not ' : ''}keep the dialog mounted when keepMounted=${keepMounted}`, async () => { const { queryByRole } = await render( - + + + , ); @@ -50,10 +52,12 @@ describe('', () => { Open - - - - + + + + + + , @@ -78,12 +82,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -114,12 +120,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -148,9 +156,11 @@ describe('', () => { Open - - Close - + + + Close + + , @@ -176,9 +186,11 @@ describe('', () => { Open - - Close - + + + Close + + diff --git a/packages/react/src/dialog/popup/DialogPopup.tsx b/packages/react/src/dialog/popup/DialogPopup.tsx index be21ee3681..2d80874ffa 100644 --- a/packages/react/src/dialog/popup/DialogPopup.tsx +++ b/packages/react/src/dialog/popup/DialogPopup.tsx @@ -16,6 +16,7 @@ import { transitionStatusMapping } from '../../utils/styleHookMapping'; import { DialogPopupCssVars } from './DialogPopupCssVars'; import { DialogPopupDataAttributes } from './DialogPopupDataAttributes'; import { InternalBackdrop } from '../../utils/InternalBackdrop'; +import { useDialogPortalContext } from '../portal/DialogPortalContext'; const customStyleHookMapping: CustomStyleHookMapping = { ...baseMapping, @@ -35,7 +36,7 @@ const DialogPopup = React.forwardRef(function DialogPopup( props: DialogPopup.Props, forwardedRef: React.ForwardedRef, ) { - const { className, finalFocus, id, initialFocus, keepMounted = false, render, ...other } = props; + const { className, finalFocus, id, initialFocus, render, ...other } = props; const { descriptionElementId, @@ -56,6 +57,8 @@ const DialogPopup = React.forwardRef(function DialogPopup( transitionStatus, } = useDialogRootContext(); + useDialogPortalContext(); + const mergedRef = useForkRef(forwardedRef, popupRef); const { getRootProps, floatingContext, resolvedInitialFocus } = useDialogPopup({ @@ -94,10 +97,6 @@ const DialogPopup = React.forwardRef(function DialogPopup( customStyleHookMapping, }); - if (!keepMounted && !mounted) { - return null; - } - return ( {mounted && modal && } @@ -118,11 +117,6 @@ const DialogPopup = React.forwardRef(function DialogPopup( namespace DialogPopup { export interface Props extends BaseUIComponentProps<'div', State> { - /** - * Whether to keep the HTML element in the DOM while the dialog is hidden. - * @default false - */ - keepMounted?: boolean; /** * Determines the element to focus when the dialog is opened. * By default, the first focusable element is focused. @@ -185,11 +179,6 @@ DialogPopup.propTypes /* remove-proptypes */ = { PropTypes.func, refType, ]), - /** - * Whether to keep the HTML element in the DOM while the dialog is hidden. - * @default false - */ - keepMounted: PropTypes.bool, /** * Allows you to replace the component’s HTML element * with a different tag, or compose it with another component. diff --git a/packages/react/src/dialog/portal/DialogPortal.tsx b/packages/react/src/dialog/portal/DialogPortal.tsx new file mode 100644 index 0000000000..1f0a85ab52 --- /dev/null +++ b/packages/react/src/dialog/portal/DialogPortal.tsx @@ -0,0 +1,67 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingPortal } from '@floating-ui/react'; +import { useDialogRootContext } from '../root/DialogRootContext'; +import { DialogPortalContext } from './DialogPortalContext'; +import { HTMLElementType, refType } from '../../utils/proptypes'; + +/** + * A portal element that moves the popup to a different part of the DOM. + * By default, the portal element is appended to ``. + * + * Documentation: [Base UI Dialog](https://base-ui.com/react/components/dialog) + */ +function DialogPortal(props: DialogPortal.Props) { + const { children, keepMounted = false, container } = props; + + const { mounted } = useDialogRootContext(); + + const shouldRender = mounted || keepMounted; + if (!shouldRender) { + return null; + } + + return ( + + {children} + + ); +} + +namespace DialogPortal { + export interface Props { + children?: React.ReactNode; + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted?: boolean; + /** + * A parent element to render the portal element into. + */ + container?: HTMLElement | null | React.RefObject; + } +} + +DialogPortal.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, + /** + * A parent element to render the portal element into. + */ + container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]), + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted: PropTypes.bool, +} as any; + +export { DialogPortal }; diff --git a/packages/react/src/dialog/portal/DialogPortalContext.ts b/packages/react/src/dialog/portal/DialogPortalContext.ts new file mode 100644 index 0000000000..60a523f117 --- /dev/null +++ b/packages/react/src/dialog/portal/DialogPortalContext.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export const DialogPortalContext = React.createContext(undefined); + +export function useDialogPortalContext() { + const value = React.useContext(DialogPortalContext); + if (value === undefined) { + throw new Error('Base UI: is missing.'); + } + return value; +} diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx index 6faa878510..b9a4f2b9f1 100644 --- a/packages/react/src/dialog/root/DialogRoot.test.tsx +++ b/packages/react/src/dialog/root/DialogRoot.test.tsx @@ -21,7 +21,9 @@ describe('', () => { const { queryByRole, getByRole } = await render( - + + + , ); @@ -40,7 +42,9 @@ describe('', () => { it('should open and close the dialog with the `open` prop', async () => { const { queryByRole, setProps } = await render( - + + + , ); @@ -67,7 +71,9 @@ describe('', () => {
- + + +
); @@ -122,12 +128,13 @@ describe('', () => {