From 5de02b5e290495567aa933f5b5069c1eb674c8ac Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 26 Sep 2025 13:21:48 +1000 Subject: [PATCH 1/6] [portals] Ensure container is reactive from undefined --- .../components/FloatingPortal.test.tsx | 78 +++++++++------ .../components/FloatingPortal.tsx | 95 +++++++------------ 2 files changed, 83 insertions(+), 90 deletions(-) diff --git a/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx b/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx index a4ee6c681a..a879524ef9 100644 --- a/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx @@ -1,13 +1,14 @@ -import { fireEvent, flushMicrotasks, render, screen } from '@mui/internal-test-utils'; import * as React from 'react'; - +import { fireEvent, flushMicrotasks, render, screen } from '@mui/internal-test-utils'; import { isJSDOM } from '@base-ui-components/utils/detectBrowser'; import { FloatingPortal, useFloating } from '../index'; +import type { UseFloatingPortalNodeProps } from './FloatingPortal'; + +interface AppProps { + root?: UseFloatingPortalNodeProps['root']; +} -function App(props: { - root?: HTMLElement | null | React.RefObject; - id?: string; -}) { +function App(props: AppProps) { const [open, setOpen] = React.useState(false); const { refs } = useFloating({ open, @@ -25,30 +26,6 @@ function App(props: { } describe.skipIf(!isJSDOM)('FloatingPortal', () => { - test('creates a custom id node', async () => { - render(); - await flushMicrotasks(); - expect(document.querySelector('#custom-id')).toBeInTheDocument(); - }); - - test('uses a custom id node as the root', async () => { - const customRoot = document.createElement('div'); - customRoot.id = 'custom-root'; - document.body.appendChild(customRoot); - render(); - fireEvent.click(screen.getByTestId('reference')); - await flushMicrotasks(); - expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(customRoot); - customRoot.remove(); - }); - - test('creates a custom id node as the root', async () => { - render(); - fireEvent.click(screen.getByTestId('reference')); - await flushMicrotasks(); - expect(screen.getByTestId('floating').parentElement?.parentElement?.id).toBe('custom-id'); - }); - test('allows custom roots', async () => { const customRoot = document.createElement('div'); customRoot.id = 'custom-root'; @@ -89,7 +66,7 @@ describe.skipIf(!isJSDOM)('FloatingPortal', () => { return ( {renderRoot &&
} - ; + ); } @@ -103,4 +80,43 @@ describe.skipIf(!isJSDOM)('FloatingPortal', () => { const root = screen.getByTestId('root'); expect(root).toBe(subRoot?.parentElement); }); + + test('reattaches the portal when the root changes', async () => { + const customRoot = document.createElement('div'); + document.body.appendChild(customRoot); + + try { + function RootSwitcher() { + const [root, setRoot] = React.useState(undefined); + + return ( + + + {open && ( - +
{children} @@ -978,8 +978,8 @@ describe.skipIf(!isJSDOM)('useDismiss', () => { return ( - - + + diff --git a/packages/react/src/floating-ui-react/types.ts b/packages/react/src/floating-ui-react/types.ts index 82a0c430dd..7892ef67ce 100644 --- a/packages/react/src/floating-ui-react/types.ts +++ b/packages/react/src/floating-ui-react/types.ts @@ -11,7 +11,7 @@ import type { ExtendedUserProps } from './hooks/useInteractions'; export * from '.'; export type { FloatingDelayGroupProps } from './components/FloatingDelayGroup'; export type { FloatingFocusManagerProps } from './components/FloatingFocusManager'; -export type { FloatingPortalProps, UseFloatingPortalNodeProps } from './components/FloatingPortal'; +export type { UseFloatingPortalNodeProps } from './components/FloatingPortal'; export type { UseClientPointProps } from './hooks/useClientPoint'; export type { UseDismissProps } from './hooks/useDismiss'; export type { UseFocusProps } from './hooks/useFocus'; diff --git a/packages/react/src/menu/portal/MenuPortal.tsx b/packages/react/src/menu/portal/MenuPortal.tsx index 390a907eab..48865bd274 100644 --- a/packages/react/src/menu/portal/MenuPortal.tsx +++ b/packages/react/src/menu/portal/MenuPortal.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { FloatingPortal, FloatingPortalProps } from '../../floating-ui-react'; +import { FloatingPortal } from '../../floating-ui-react'; import { useMenuRootContext } from '../root/MenuRootContext'; import { MenuPortalContext } from './MenuPortalContext'; @@ -10,8 +10,11 @@ import { MenuPortalContext } from './MenuPortalContext'; * * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ -export function MenuPortal(props: MenuPortal.Props) { - const { children, keepMounted = false, container } = props; +export const MenuPortal = React.forwardRef(function MenuPortal( + props: MenuPortal.Props, + forwardedRef: React.ForwardedRef, +) { + const { children, keepMounted = false, ...portalProps } = props; const { mounted } = useMenuRootContext(); @@ -22,14 +25,15 @@ export function MenuPortal(props: MenuPortal.Props) { return ( - {children} + + {children} + ); -} +}); export namespace MenuPortal { - export interface Props { - children?: React.ReactNode; + export interface Props extends FloatingPortal.Props { /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false @@ -38,6 +42,6 @@ export namespace MenuPortal { /** * A parent element to render the portal element into. */ - container?: FloatingPortalProps['root']; + container?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx index 1a4d46eed1..5c150caa36 100644 --- a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx +++ b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx @@ -10,8 +10,11 @@ import { NavigationMenuPortalContext } from './NavigationMenuPortalContext'; * * Documentation: [Base UI Navigation Menu](https://base-ui.com/react/components/navigation-menu) */ -export function NavigationMenuPortal(props: NavigationMenuPortal.Props) { - const { children, keepMounted = false, container } = props; +export const NavigationMenuPortal = React.forwardRef(function NavigationMenuPortal( + props: NavigationMenuPortal.Props, + forwardedRef: React.ForwardedRef, +) { + const { children, keepMounted = false, ...portalProps } = props; const { mounted } = useNavigationMenuRootContext(); @@ -22,13 +25,15 @@ export function NavigationMenuPortal(props: NavigationMenuPortal.Props) { return ( - {children} + + {children} + ); -} +}); export namespace NavigationMenuPortal { - export interface Props { + export interface Props extends FloatingPortal.Props { children?: React.ReactNode; /** * Whether to keep the portal mounted in the DOM while the popup is hidden. @@ -38,6 +43,6 @@ export namespace NavigationMenuPortal { /** * A parent element to render the portal element into. */ - container?: HTMLElement | null | React.RefObject; + container?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/popover/portal/PopoverPortal.tsx b/packages/react/src/popover/portal/PopoverPortal.tsx index 9d5d2824c6..5aa250ebe0 100644 --- a/packages/react/src/popover/portal/PopoverPortal.tsx +++ b/packages/react/src/popover/portal/PopoverPortal.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { FloatingPortal, FloatingPortalProps } from '../../floating-ui-react'; +import { FloatingPortal } from '../../floating-ui-react'; import { usePopoverRootContext } from '../root/PopoverRootContext'; import { PopoverPortalContext } from './PopoverPortalContext'; @@ -10,8 +10,11 @@ import { PopoverPortalContext } from './PopoverPortalContext'; * * Documentation: [Base UI Popover](https://base-ui.com/react/components/popover) */ -export function PopoverPortal(props: PopoverPortal.Props) { - const { children, keepMounted = false, container } = props; +export const PopoverPortal = React.forwardRef(function PopoverPortal( + props: PopoverPortal.Props, + forwardedRef: React.ForwardedRef, +) { + const { children, keepMounted = false, ...portalProps } = props; const { mounted } = usePopoverRootContext(); @@ -22,14 +25,15 @@ export function PopoverPortal(props: PopoverPortal.Props) { return ( - {children} + + {children} + ); -} +}); export namespace PopoverPortal { - export interface Props { - children?: React.ReactNode; + export interface Props extends FloatingPortal.Props { /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false @@ -38,6 +42,6 @@ export namespace PopoverPortal { /** * A parent element to render the portal element into. */ - container?: FloatingPortalProps['root']; + container?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/preview-card/portal/PreviewCardPortal.tsx b/packages/react/src/preview-card/portal/PreviewCardPortal.tsx index 81e0b90df1..bddb43f519 100644 --- a/packages/react/src/preview-card/portal/PreviewCardPortal.tsx +++ b/packages/react/src/preview-card/portal/PreviewCardPortal.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { usePreviewCardRootContext } from '../root/PreviewCardContext'; import { PreviewCardPortalContext } from './PreviewCardPortalContext'; import { FloatingPortalLite } from '../../utils/FloatingPortalLite'; -import type { FloatingPortalProps } from '../../floating-ui-react'; /** * A portal element that moves the popup to a different part of the DOM. @@ -11,8 +10,11 @@ import type { FloatingPortalProps } from '../../floating-ui-react'; * * Documentation: [Base UI Preview Card](https://base-ui.com/react/components/preview-card) */ -export function PreviewCardPortal(props: PreviewCardPortal.Props) { - const { children, keepMounted = false, container } = props; +export const PreviewCardPortal = React.forwardRef(function PreviewCardPortal( + props: PreviewCardPortal.Props, + forwardedRef: React.ForwardedRef, +) { + const { children, keepMounted = false, ...portalProps } = props; const { mounted } = usePreviewCardRootContext(); @@ -23,14 +25,15 @@ export function PreviewCardPortal(props: PreviewCardPortal.Props) { return ( - {children} + + {children} + ); -} +}); export namespace PreviewCardPortal { - export interface Props { - children?: React.ReactNode; + export interface Props extends FloatingPortalLite.Props { /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false @@ -39,6 +42,6 @@ export namespace PreviewCardPortal { /** * A parent element to render the portal element into. */ - container?: FloatingPortalProps['root']; + container?: FloatingPortalLite.Props['container']; } } diff --git a/packages/react/src/select/portal/SelectPortal.tsx b/packages/react/src/select/portal/SelectPortal.tsx index 9bf4e1a711..33e9934379 100644 --- a/packages/react/src/select/portal/SelectPortal.tsx +++ b/packages/react/src/select/portal/SelectPortal.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { useStore } from '@base-ui-components/utils/store'; -import { FloatingPortal, FloatingPortalProps } from '../../floating-ui-react'; +import { FloatingPortal } from '../../floating-ui-react'; import { SelectPortalContext } from './SelectPortalContext'; import { useSelectRootContext } from '../root/SelectRootContext'; import { selectors } from '../store'; @@ -12,8 +12,11 @@ import { selectors } from '../store'; * * Documentation: [Base UI Select](https://base-ui.com/react/components/select) */ -export function SelectPortal(props: SelectPortal.Props) { - const { children, container } = props; +export const SelectPortal = React.forwardRef(function SelectPortal( + props: SelectPortal.Props, + forwardedRef: React.ForwardedRef, +) { + const { children, ...portalProps } = props; const { store } = useSelectRootContext(); const mounted = useStore(store, selectors.mounted); @@ -26,17 +29,18 @@ export function SelectPortal(props: SelectPortal.Props) { return ( - {children} + + {children} + ); -} +}); export namespace SelectPortal { - export interface Props { - children?: React.ReactNode; + export interface Props extends FloatingPortal.Props { /** * A parent element to render the portal element into. */ - container?: FloatingPortalProps['root']; + container?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/toast/portal/ToastPortal.tsx b/packages/react/src/toast/portal/ToastPortal.tsx index 6118c81296..9cd25bc0d7 100644 --- a/packages/react/src/toast/portal/ToastPortal.tsx +++ b/packages/react/src/toast/portal/ToastPortal.tsx @@ -1,5 +1,4 @@ 'use client'; -import * as React from 'react'; import { FloatingPortalLite } from '../../utils/FloatingPortalLite'; /** @@ -8,17 +7,13 @@ import { FloatingPortalLite } from '../../utils/FloatingPortalLite'; * * Documentation: [Base UI Toast](https://base-ui.com/react/components/toast) */ -export function ToastPortal(props: ToastPortal.Props) { - const { children, container } = props; - return {children}; -} +export const ToastPortal = FloatingPortalLite; export namespace ToastPortal { - export interface Props { - children?: React.ReactNode; + export interface Props extends FloatingPortalLite.Props { /** * A parent element to render the portal element into. */ - container?: HTMLElement | null | React.RefObject; + container?: FloatingPortalLite.Props['container']; } } diff --git a/packages/react/src/tooltip/portal/TooltipPortal.tsx b/packages/react/src/tooltip/portal/TooltipPortal.tsx index 66af7c20d7..757b3e1c4f 100644 --- a/packages/react/src/tooltip/portal/TooltipPortal.tsx +++ b/packages/react/src/tooltip/portal/TooltipPortal.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { useTooltipRootContext } from '../root/TooltipRootContext'; import { TooltipPortalContext } from './TooltipPortalContext'; import { FloatingPortalLite } from '../../utils/FloatingPortalLite'; -import type { FloatingPortalProps } from '../../floating-ui-react'; /** * A portal element that moves the popup to a different part of the DOM. @@ -11,8 +10,11 @@ import type { FloatingPortalProps } from '../../floating-ui-react'; * * Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip) */ -export function TooltipPortal(props: TooltipPortal.Props) { - const { children, keepMounted = false, container } = props; +export const TooltipPortal = React.forwardRef(function TooltipPortal( + props: TooltipPortal.Props, + forwardedRef: React.ForwardedRef, +) { + const { children, keepMounted = false, ...portalProps } = props; const { mounted } = useTooltipRootContext(); @@ -23,14 +25,15 @@ export function TooltipPortal(props: TooltipPortal.Props) { return ( - {children} + + {children} + ); -} +}); export namespace TooltipPortal { - export interface Props { - children?: React.ReactNode; + export interface Props extends FloatingPortalLite.Props { /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false @@ -39,6 +42,6 @@ export namespace TooltipPortal { /** * A parent element to render the portal element into. */ - container?: FloatingPortalProps['root']; + container?: FloatingPortalLite.Props['container']; } } diff --git a/packages/react/src/utils/FloatingPortalLite.tsx b/packages/react/src/utils/FloatingPortalLite.tsx index b01f7cc55a..a04a71216a 100644 --- a/packages/react/src/utils/FloatingPortalLite.tsx +++ b/packages/react/src/utils/FloatingPortalLite.tsx @@ -1,20 +1,47 @@ 'use client'; +import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { FloatingPortalProps, useFloatingPortalNode } from '../floating-ui-react'; +import { useFloatingPortalNode, type FloatingPortal } from '../floating-ui-react'; +import type { BaseUIComponentProps } from '../utils/types'; /** * `FloatingPortal` includes tabbable logic handling for focus management. * For components that don't need tabbable logic, use `FloatingPortalLite`. * @internal */ -export function FloatingPortalLite(props: FloatingPortalLite.Props) { - const node = useFloatingPortalNode({ root: props.root }); - return node && ReactDOM.createPortal(props.children, node); -} +export const FloatingPortalLite = React.forwardRef(function FloatingPortalLite( + props: FloatingPortalLite.Props, + forwardedRef: React.ForwardedRef, +) { + const { children, container, className, render, ...elementProps } = props; + + const componentProps = React.useMemo(() => ({ className, render }), [className, render]); + + const { portalNode, portalSubtree } = useFloatingPortalNode({ + container, + ref: forwardedRef, + componentProps, + elementProps, + }); + + if (!portalSubtree && !portalNode) { + return null; + } + + return ( + + {portalSubtree} + {portalNode && ReactDOM.createPortal(children, portalNode)} + + ); +}); export namespace FloatingPortalLite { - export interface Props { + export interface State {} + + export interface Props + extends BaseUIComponentProps<'div', State, React.HTMLAttributes> { children?: React.ReactNode; - root?: FloatingPortalProps['root']; + container?: FloatingPortal.Props['container']; } } From 1c16f658340ea79badcf6e8bf0143eb2053ef538 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 2 Oct 2025 19:45:52 +1000 Subject: [PATCH 3/6] docs:api --- docs/reference/generated/alert-dialog-portal.json | 9 +++++++++ docs/reference/generated/combobox-portal.json | 13 +++++++++++-- docs/reference/generated/dialog-portal.json | 11 ++++++++++- docs/reference/generated/menu-portal.json | 9 +++++++++ .../generated/navigation-menu-portal.json | 13 +++++++++++-- docs/reference/generated/popover-portal.json | 9 +++++++++ docs/reference/generated/preview-card-portal.json | 10 ++++++++++ docs/reference/generated/select-portal.json | 9 +++++++++ docs/reference/generated/toast-portal.json | 15 ++++++++++++--- docs/reference/generated/tooltip-portal.json | 10 ++++++++++ 10 files changed, 100 insertions(+), 8 deletions(-) diff --git a/docs/reference/generated/alert-dialog-portal.json b/docs/reference/generated/alert-dialog-portal.json index 31014bc670..43a7717d5b 100644 --- a/docs/reference/generated/alert-dialog-portal.json +++ b/docs/reference/generated/alert-dialog-portal.json @@ -11,11 +11,20 @@ "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "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 portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLProps, state: {}) => 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.", + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/combobox-portal.json b/docs/reference/generated/combobox-portal.json index 1514ab86c1..fb8583ace8 100644 --- a/docs/reference/generated/combobox-portal.json +++ b/docs/reference/generated/combobox-portal.json @@ -3,19 +3,28 @@ "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": "HTMLElement | RefObject | null", + "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", - "detailedType": "| HTMLElement\n| React.RefObject\n| null\n| undefined" + "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, "children": { "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "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 portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLProps, state: {}) => 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.", + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/dialog-portal.json b/docs/reference/generated/dialog-portal.json index 4be121c6d1..bfd1dc8609 100644 --- a/docs/reference/generated/dialog-portal.json +++ b/docs/reference/generated/dialog-portal.json @@ -1,6 +1,6 @@ { "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 ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", @@ -11,11 +11,20 @@ "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "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 portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLProps, state: {}) => 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.", + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/menu-portal.json b/docs/reference/generated/menu-portal.json index ca33cd284b..08efbfa789 100644 --- a/docs/reference/generated/menu-portal.json +++ b/docs/reference/generated/menu-portal.json @@ -11,11 +11,20 @@ "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "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 portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLProps, state: {}) => 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.", + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/navigation-menu-portal.json b/docs/reference/generated/navigation-menu-portal.json index 29b172d752..6e0b5a09db 100644 --- a/docs/reference/generated/navigation-menu-portal.json +++ b/docs/reference/generated/navigation-menu-portal.json @@ -3,19 +3,28 @@ "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": "HTMLElement | RefObject | null", + "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", - "detailedType": "| HTMLElement\n| React.RefObject\n| null\n| undefined" + "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, "children": { "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "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 portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLProps, state: {}) => 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.", + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/popover-portal.json b/docs/reference/generated/popover-portal.json index b69dc39f37..615cc61b55 100644 --- a/docs/reference/generated/popover-portal.json +++ b/docs/reference/generated/popover-portal.json @@ -11,11 +11,20 @@ "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "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 portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLProps, state: {}) => 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.", + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/preview-card-portal.json b/docs/reference/generated/preview-card-portal.json index b2a55326c4..0c5b8d3a71 100644 --- a/docs/reference/generated/preview-card-portal.json +++ b/docs/reference/generated/preview-card-portal.json @@ -11,11 +11,21 @@ "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "type": "string | ((state: FloatingPortalLite.State) => string)", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", + "detailedType": "| string\n| ((state: FloatingPortalLite.State) => string)" + }, "keepMounted": { "type": "boolean", "default": "false", "description": "Whether to keep the portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLAttributes, state: FloatingPortalLite.State) => 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.", + "detailedType": "| ReactElement\n| ((\n props: HTMLAttributes,\n state: FloatingPortalLite.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/select-portal.json b/docs/reference/generated/select-portal.json index 7b1e69db10..f1a2cc2959 100644 --- a/docs/reference/generated/select-portal.json +++ b/docs/reference/generated/select-portal.json @@ -10,6 +10,15 @@ "children": { "type": "ReactNode", "detailedType": "React.ReactNode" + }, + "className": { + "type": "string | ((state: {}) => string)", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + }, + "render": { + "type": "ReactElement | ((props: HTMLProps, state: {}) => 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.", + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/toast-portal.json b/docs/reference/generated/toast-portal.json index e2b2aa546c..fb1eb46f7a 100644 --- a/docs/reference/generated/toast-portal.json +++ b/docs/reference/generated/toast-portal.json @@ -3,13 +3,22 @@ "description": "A portal element that moves the viewport to a different part of the DOM.\nBy default, the portal element is appended to ``.", "props": { "container": { - "type": "HTMLElement | RefObject | null", - "description": "A parent element to render the portal element into.", - "detailedType": "| HTMLElement\n| React.RefObject\n| null\n| undefined" + "type": "HTMLElement | ShadowRoot | RefObject | null", + "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, "children": { "type": "ReactNode", "detailedType": "React.ReactNode" + }, + "className": { + "type": "string | ((state: FloatingPortalLite.State) => string)", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", + "detailedType": "| string\n| ((state: FloatingPortalLite.State) => string)" + }, + "render": { + "type": "ReactElement | ((props: HTMLAttributes, state: FloatingPortalLite.State) => 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.", + "detailedType": "| ReactElement\n| ((\n props: HTMLAttributes,\n state: FloatingPortalLite.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/tooltip-portal.json b/docs/reference/generated/tooltip-portal.json index 2ddeb24196..74993a5313 100644 --- a/docs/reference/generated/tooltip-portal.json +++ b/docs/reference/generated/tooltip-portal.json @@ -11,11 +11,21 @@ "type": "ReactNode", "detailedType": "React.ReactNode" }, + "className": { + "type": "string | ((state: FloatingPortalLite.State) => string)", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", + "detailedType": "| string\n| ((state: FloatingPortalLite.State) => string)" + }, "keepMounted": { "type": "boolean", "default": "false", "description": "Whether to keep the portal mounted in the DOM while the popup is hidden.", "detailedType": "boolean | undefined" + }, + "render": { + "type": "ReactElement | ((props: HTMLAttributes, state: FloatingPortalLite.State) => 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.", + "detailedType": "| ReactElement\n| ((\n props: HTMLAttributes,\n state: FloatingPortalLite.State,\n ) => ReactElement)" } }, "dataAttributes": {}, From 3da63bf78055453680468dbd28a863490cbe02e1 Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 3 Oct 2025 12:18:41 +1000 Subject: [PATCH 4/6] Fix DemoLoader --- docs/src/components/Demo/DemoLoader.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/src/components/Demo/DemoLoader.tsx b/docs/src/components/Demo/DemoLoader.tsx index 8d4420e904..7632162584 100644 --- a/docs/src/components/Demo/DemoLoader.tsx +++ b/docs/src/components/Demo/DemoLoader.tsx @@ -254,6 +254,21 @@ function resolveExtensionlessFile(filePath: string, preferTs: boolean): string { ? ['.tsx', '.ts', '.jsx', '.js', '.json'] : ['.jsx', '.js', '.tsx', '.ts', '.json']; + if (existsSync(filePath)) { + const stats = statSync(filePath); + if (stats.isFile()) { + return filePath; + } + if (stats.isDirectory()) { + for (const extension of extensions) { + const indexPath = join(filePath, `index${extension}`); + if (existsSync(indexPath)) { + return indexPath; + } + } + } + } + for (const extension of extensions) { const fullPath = `${filePath}${extension}`; if (existsSync(fullPath)) { From 5d77d0ffb890d5ad095eb3b5261a927bdb04f7f5 Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 3 Oct 2025 12:20:31 +1000 Subject: [PATCH 5/6] lint --- .../generated/alert-dialog-portal.json | 15 ++-- docs/reference/generated/combobox-portal.json | 12 ++-- docs/reference/generated/dialog-portal.json | 10 +-- docs/reference/generated/menu-portal.json | 12 ++-- .../generated/navigation-menu-portal.json | 15 ++-- docs/reference/generated/popover-portal.json | 12 ++-- .../generated/preview-card-portal.json | 14 ++-- docs/reference/generated/select-portal.json | 12 ++-- docs/reference/generated/toast-portal.json | 16 ++--- docs/reference/generated/tooltip-portal.json | 15 ++-- .../portal/AlertDialogPortal.test.tsx | 14 ++++ .../alert-dialog/portal/AlertDialogPortal.tsx | 13 ++-- .../combobox/portal/ComboboxPortal.test.tsx | 14 ++++ .../src/combobox/portal/ComboboxPortal.tsx | 16 ++--- .../src/dialog/portal/DialogPortal.test.tsx | 14 ++++ .../react/src/dialog/portal/DialogPortal.tsx | 12 ++-- .../components/FloatingPortal.tsx | 71 +++++++++---------- .../react/src/menu/portal/MenuPortal.test.tsx | 14 ++++ packages/react/src/menu/portal/MenuPortal.tsx | 15 ++-- .../portal/NavigationMenuPortal.test.tsx | 14 ++++ .../portal/NavigationMenuPortal.tsx | 14 ++-- .../src/popover/portal/PopoverPortal.test.tsx | 14 ++++ .../src/popover/portal/PopoverPortal.tsx | 15 ++-- .../portal/PreviewCardPortal.test.tsx | 14 ++++ .../preview-card/portal/PreviewCardPortal.tsx | 15 ++-- .../src/select/portal/SelectPortal.test.tsx | 14 ++++ .../react/src/select/portal/SelectPortal.tsx | 18 ++--- .../src/toast/portal/ToastPortal.test.tsx | 14 ++++ .../react/src/toast/portal/ToastPortal.tsx | 10 ++- .../src/tooltip/portal/TooltipPortal.test.tsx | 14 ++++ .../src/tooltip/portal/TooltipPortal.tsx | 15 ++-- .../react/src/utils/FloatingPortalLite.tsx | 15 +--- 32 files changed, 284 insertions(+), 218 deletions(-) create mode 100644 packages/react/src/alert-dialog/portal/AlertDialogPortal.test.tsx create mode 100644 packages/react/src/combobox/portal/ComboboxPortal.test.tsx create mode 100644 packages/react/src/dialog/portal/DialogPortal.test.tsx create mode 100644 packages/react/src/menu/portal/MenuPortal.test.tsx create mode 100644 packages/react/src/navigation-menu/portal/NavigationMenuPortal.test.tsx create mode 100644 packages/react/src/popover/portal/PopoverPortal.test.tsx create mode 100644 packages/react/src/preview-card/portal/PreviewCardPortal.test.tsx create mode 100644 packages/react/src/select/portal/SelectPortal.test.tsx create mode 100644 packages/react/src/toast/portal/ToastPortal.test.tsx create mode 100644 packages/react/src/tooltip/portal/TooltipPortal.test.tsx diff --git a/docs/reference/generated/alert-dialog-portal.json b/docs/reference/generated/alert-dialog-portal.json index 43a7717d5b..fbdb9ede3e 100644 --- a/docs/reference/generated/alert-dialog-portal.json +++ b/docs/reference/generated/alert-dialog-portal.json @@ -1,19 +1,16 @@ { "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 ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: {}) => string)", - "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + "type": "string | ((state: AlertDialog.Portal.State) => string)", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", + "detailedType": "| string\n| ((state: AlertDialog.Portal.State) => string)" }, "keepMounted": { "type": "boolean", @@ -22,9 +19,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLProps, state: {}) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: AlertDialog.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: AlertDialog.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/combobox-portal.json b/docs/reference/generated/combobox-portal.json index fb8583ace8..431f648701 100644 --- a/docs/reference/generated/combobox-portal.json +++ b/docs/reference/generated/combobox-portal.json @@ -1,18 +1,14 @@ { "name": "ComboboxPortal", - "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: {}) => string)", + "type": "string | ((state: Combobox.Portal.State) => string)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, "keepMounted": { @@ -22,9 +18,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLProps, state: {}) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: Combobox.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: Combobox.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/dialog-portal.json b/docs/reference/generated/dialog-portal.json index bfd1dc8609..b58bd81919 100644 --- a/docs/reference/generated/dialog-portal.json +++ b/docs/reference/generated/dialog-portal.json @@ -7,12 +7,8 @@ "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: {}) => string)", + "type": "string | ((state: Dialog.Portal.State) => string)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, "keepMounted": { @@ -22,9 +18,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLProps, state: {}) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: Dialog.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: Dialog.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/menu-portal.json b/docs/reference/generated/menu-portal.json index 08efbfa789..376eaf5c63 100644 --- a/docs/reference/generated/menu-portal.json +++ b/docs/reference/generated/menu-portal.json @@ -1,18 +1,14 @@ { "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 ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: {}) => string)", + "type": "string | ((state: Menu.Portal.State) => string)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, "keepMounted": { @@ -22,9 +18,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLProps, state: {}) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: Menu.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: Menu.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/navigation-menu-portal.json b/docs/reference/generated/navigation-menu-portal.json index 6e0b5a09db..f2f7d7aa69 100644 --- a/docs/reference/generated/navigation-menu-portal.json +++ b/docs/reference/generated/navigation-menu-portal.json @@ -1,19 +1,16 @@ { "name": "NavigationMenuPortal", - "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: {}) => string)", - "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + "type": "string | ((state: NavigationMenu.Portal.State) => string)", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", + "detailedType": "| string\n| ((state: NavigationMenu.Portal.State) => string)" }, "keepMounted": { "type": "boolean", @@ -22,9 +19,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLProps, state: {}) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: NavigationMenu.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: NavigationMenu.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/popover-portal.json b/docs/reference/generated/popover-portal.json index 615cc61b55..6bdb4f5fd7 100644 --- a/docs/reference/generated/popover-portal.json +++ b/docs/reference/generated/popover-portal.json @@ -1,18 +1,14 @@ { "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 ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: {}) => string)", + "type": "string | ((state: Popover.Portal.State) => string)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, "keepMounted": { @@ -22,9 +18,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLProps, state: {}) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: Popover.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: Popover.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/preview-card-portal.json b/docs/reference/generated/preview-card-portal.json index 0c5b8d3a71..5605d1bcab 100644 --- a/docs/reference/generated/preview-card-portal.json +++ b/docs/reference/generated/preview-card-portal.json @@ -1,20 +1,16 @@ { "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 ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: FloatingPortalLite.State) => string)", + "type": "string | ((state: PreviewCard.Portal.State) => string)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", - "detailedType": "| string\n| ((state: FloatingPortalLite.State) => string)" + "detailedType": "| string\n| ((state: PreviewCard.Portal.State) => string)" }, "keepMounted": { "type": "boolean", @@ -23,9 +19,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLAttributes, state: FloatingPortalLite.State) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: PreviewCard.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((\n props: HTMLAttributes,\n state: FloatingPortalLite.State,\n ) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: PreviewCard.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/select-portal.json b/docs/reference/generated/select-portal.json index f1a2cc2959..5bfb92091f 100644 --- a/docs/reference/generated/select-portal.json +++ b/docs/reference/generated/select-portal.json @@ -1,24 +1,20 @@ { "name": "SelectPortal", - "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: {}) => string)", + "type": "string | ((state: Select.Portal.State) => string)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, "render": { - "type": "ReactElement | ((props: HTMLProps, state: {}) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: Select.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((props: HTMLProps, state: {}) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: Select.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/toast-portal.json b/docs/reference/generated/toast-portal.json index fb1eb46f7a..96a6afedc9 100644 --- a/docs/reference/generated/toast-portal.json +++ b/docs/reference/generated/toast-portal.json @@ -1,24 +1,20 @@ { "name": "ToastPortal", - "description": "A portal element that moves the viewport to a different part of the DOM.\nBy default, the portal element is appended to ``.", + "description": "A portal element that moves the viewport to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", + "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: FloatingPortalLite.State) => string)", - "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", - "detailedType": "| string\n| ((state: FloatingPortalLite.State) => string)" + "type": "string | ((state: any) => string)", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." }, "render": { - "type": "ReactElement | ((props: HTMLAttributes, state: FloatingPortalLite.State) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: any) => 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.", - "detailedType": "| ReactElement\n| ((\n props: HTMLAttributes,\n state: FloatingPortalLite.State,\n ) => ReactElement)" + "detailedType": "| ReactElement\n| ((props: HTMLProps, state: any) => ReactElement)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/tooltip-portal.json b/docs/reference/generated/tooltip-portal.json index 74993a5313..11f2e15907 100644 --- a/docs/reference/generated/tooltip-portal.json +++ b/docs/reference/generated/tooltip-portal.json @@ -1,20 +1,15 @@ { "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 ``.", + "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.\nRenders a `
` element.", "props": { "container": { "type": "HTMLElement | ShadowRoot | RefObject | null", "description": "A parent element to render the portal element into.", "detailedType": "| HTMLElement\n| ShadowRoot\n| React.RefObject\n| null\n| undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { - "type": "string | ((state: FloatingPortalLite.State) => string)", - "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", - "detailedType": "| string\n| ((state: FloatingPortalLite.State) => string)" + "type": "string | ((state: Tooltip.Portal.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", @@ -23,9 +18,9 @@ "detailedType": "boolean | undefined" }, "render": { - "type": "ReactElement | ((props: HTMLAttributes, state: FloatingPortalLite.State) => ReactElement)", + "type": "ReactElement | ((props: HTMLProps, state: Tooltip.Portal.State) => 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.", - "detailedType": "| ReactElement\n| ((\n props: HTMLAttributes,\n state: FloatingPortalLite.State,\n ) => ReactElement)" + "detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: Tooltip.Portal.State,\n ) => ReactElement)" } }, "dataAttributes": {}, diff --git a/packages/react/src/alert-dialog/portal/AlertDialogPortal.test.tsx b/packages/react/src/alert-dialog/portal/AlertDialogPortal.test.tsx new file mode 100644 index 0000000000..e7bfe1d892 --- /dev/null +++ b/packages/react/src/alert-dialog/portal/AlertDialogPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { AlertDialog } from '@base-ui-components/react/alert-dialog'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx index 2dc0ae7816..af1d2781f1 100644 --- a/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx +++ b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx @@ -7,6 +7,7 @@ import { AlertDialogPortalContext } from './AlertDialogPortalContext'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Alert Dialog](https://base-ui.com/react/components/alert-dialog) */ @@ -14,7 +15,7 @@ export const AlertDialogPortal = React.forwardRef(function AlertDialogPortal( props: AlertDialogPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const { store } = useDialogRootContext(); const mounted = store.useState('mounted'); @@ -26,15 +27,15 @@ export const AlertDialogPortal = React.forwardRef(function AlertDialogPortal( return ( - - {children} - + ); }); export namespace AlertDialogPortal { - export interface Props extends FloatingPortal.Props { + export interface State {} + + export interface Props extends FloatingPortal.Props { /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false @@ -43,6 +44,6 @@ export namespace AlertDialogPortal { /** * A parent element to render the portal element into. */ - container?: FloatingPortal.Props['container']; + container?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/combobox/portal/ComboboxPortal.test.tsx b/packages/react/src/combobox/portal/ComboboxPortal.test.tsx new file mode 100644 index 0000000000..6f990deca2 --- /dev/null +++ b/packages/react/src/combobox/portal/ComboboxPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Combobox } from '@base-ui-components/react/combobox'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/combobox/portal/ComboboxPortal.tsx b/packages/react/src/combobox/portal/ComboboxPortal.tsx index 2b086e4413..06cb4cbe86 100644 --- a/packages/react/src/combobox/portal/ComboboxPortal.tsx +++ b/packages/react/src/combobox/portal/ComboboxPortal.tsx @@ -9,12 +9,13 @@ import { selectors } from '../store'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. */ export const ComboboxPortal = React.forwardRef(function ComboboxPortal( props: ComboboxPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const store = useComboboxRootContext(); @@ -28,24 +29,19 @@ export const ComboboxPortal = React.forwardRef(function ComboboxPortal( return ( - - {children} - + ); }); export namespace ComboboxPortal { - export interface Props extends FloatingPortal.Props { - children?: React.ReactNode; + export interface State {} + + export interface Props extends FloatingPortal.Props { /** * 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?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/dialog/portal/DialogPortal.test.tsx b/packages/react/src/dialog/portal/DialogPortal.test.tsx new file mode 100644 index 0000000000..2525c58c82 --- /dev/null +++ b/packages/react/src/dialog/portal/DialogPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Dialog } from '@base-ui-components/react/dialog'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/dialog/portal/DialogPortal.tsx b/packages/react/src/dialog/portal/DialogPortal.tsx index f9f8e57e47..778d6bd4ba 100644 --- a/packages/react/src/dialog/portal/DialogPortal.tsx +++ b/packages/react/src/dialog/portal/DialogPortal.tsx @@ -15,7 +15,7 @@ export const DialogPortal = React.forwardRef(function DialogPortal( props: DialogPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const { store } = useDialogRootContext(); const mounted = store.useState('mounted'); @@ -27,15 +27,15 @@ export const DialogPortal = React.forwardRef(function DialogPortal( return ( - - {children} - + ); }); export namespace DialogPortal { - export interface Props extends FloatingPortal.Props { + export interface State {} + + export interface Props extends FloatingPortal.Props { /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false @@ -44,6 +44,6 @@ export namespace DialogPortal { /** * A parent element to render the portal element into. */ - container?: FloatingPortal.Props['container']; + container?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx index 99929e9b4f..3dc804499d 100644 --- a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx @@ -79,6 +79,7 @@ export function useFloatingPortalNode( }, []); useIsoLayoutEffect(() => { + // Wait for the container to be resolved if explicitly `null`. if (containerProp === null) { if (containerRef.current) { containerRef.current = null; @@ -93,11 +94,10 @@ export function useFloatingPortalNode( return; } - const defaultContainer = typeof document !== 'undefined' ? document.body : null; const resolvedContainer = (containerProp && (isNode(containerProp) ? containerProp : containerProp.current)) ?? parentPortalNode ?? - defaultContainer; + document.body; if (resolvedContainer == null) { if (containerRef.current) { @@ -115,25 +115,20 @@ export function useFloatingPortalNode( } }, [containerProp, parentPortalNode, uniqueId]); - const enabled = containerElement != null && uniqueId != null; - - const basePortalProps = React.useMemo(() => { - if (!enabled) { - return undefined; - } - return { - id: uniqueId, - [attr]: '', - }; - }, [enabled, uniqueId]); - const portalElement = useRenderElement('div', componentProps, { - enabled, ref: [ref, setPortalNodeRef], state: elementState, - props: enabled && basePortalProps ? [elementProps, basePortalProps] : elementProps, + props: [ + { + id: uniqueId, + [attr]: '', + }, + elementProps, + ], }); + // This `createPortal` call injects `portalElement` into the `container`. + // Another call inside `FloatingPortal`/`FloatingPortalLite` then injects the children into `portalElement`. const portalSubtree = containerElement && portalElement ? ReactDOM.createPortal(portalElement, containerElement) @@ -155,13 +150,11 @@ export function useFloatingPortalNode( * @internal */ export const FloatingPortal = React.forwardRef(function FloatingPortal( - componentProps: FloatingPortal.Props, + componentProps: FloatingPortal.Props, forwardedRef: React.ForwardedRef, ) { const { children, container, className, render, ...elementProps } = componentProps; - const [focusManagerState, setFocusManagerState] = React.useState(null); - const { portalNode, portalSubtree } = useFloatingPortalNode({ container, ref: forwardedRef, @@ -174,6 +167,8 @@ export const FloatingPortal = React.forwardRef(function FloatingPortal( const beforeInsideRef = React.useRef(null); const afterInsideRef = React.useRef(null); + const [focusManagerState, setFocusManagerState] = React.useState(null); + const modal = focusManagerState?.modal; const open = focusManagerState?.open; @@ -185,6 +180,9 @@ export const FloatingPortal = React.forwardRef(function FloatingPortal( return undefined; } + // Make sure elements inside the portal element are tabbable only when the + // portal has already been focused, either by tabbing into a focus trap + // element outside or using the mouse. function onFocus(event: FocusEvent) { if (portalNode && isOutsideEvent(event)) { const focusing = event.type === 'focusin'; @@ -193,6 +191,8 @@ export const FloatingPortal = React.forwardRef(function FloatingPortal( } } + // Listen to the event on the capture phase so they run before the focus + // trap elements onFocus prop is called. portalNode.addEventListener('focusin', onFocus, true); portalNode.addEventListener('focusout', onFocus, true); return () => { @@ -208,22 +208,22 @@ export const FloatingPortal = React.forwardRef(function FloatingPortal( enableFocusInside(portalNode); }, [open, portalNode]); + const portalContextValue = React.useMemo( + () => ({ + beforeOutsideRef, + afterOutsideRef, + beforeInsideRef, + afterInsideRef, + portalNode, + setFocusManagerState, + }), + [portalNode], + ); + return ( {portalSubtree} - ({ - beforeOutsideRef, - afterOutsideRef, - beforeInsideRef, - afterInsideRef, - portalNode, - setFocusManagerState, - }), - [portalNode], - )} - > + {shouldRenderGuards && portalNode && ( { - children?: React.ReactNode; + export interface Props extends BaseUIComponentProps<'div', State> { /** - * Specifies the container node the portal element will be appended to. + * A parent element to render the portal element into. */ container?: UseFloatingPortalNodeProps['container']; } diff --git a/packages/react/src/menu/portal/MenuPortal.test.tsx b/packages/react/src/menu/portal/MenuPortal.test.tsx new file mode 100644 index 0000000000..e542052f0b --- /dev/null +++ b/packages/react/src/menu/portal/MenuPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Menu } from '@base-ui-components/react/menu'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/menu/portal/MenuPortal.tsx b/packages/react/src/menu/portal/MenuPortal.tsx index 48865bd274..0022444b57 100644 --- a/packages/react/src/menu/portal/MenuPortal.tsx +++ b/packages/react/src/menu/portal/MenuPortal.tsx @@ -7,6 +7,7 @@ import { MenuPortalContext } from './MenuPortalContext'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ @@ -14,7 +15,7 @@ export const MenuPortal = React.forwardRef(function MenuPortal( props: MenuPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const { mounted } = useMenuRootContext(); @@ -25,23 +26,19 @@ export const MenuPortal = React.forwardRef(function MenuPortal( return ( - - {children} - + ); }); export namespace MenuPortal { - export interface Props extends FloatingPortal.Props { + export interface State {} + + export interface Props extends FloatingPortal.Props { /** * 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?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.test.tsx b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.test.tsx new file mode 100644 index 0000000000..ae097bac89 --- /dev/null +++ b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { NavigationMenu } from '@base-ui-components/react/navigation-menu'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx index 5c150caa36..1cb9799877 100644 --- a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx +++ b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx @@ -7,6 +7,7 @@ import { NavigationMenuPortalContext } from './NavigationMenuPortalContext'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Navigation Menu](https://base-ui.com/react/components/navigation-menu) */ @@ -14,7 +15,7 @@ export const NavigationMenuPortal = React.forwardRef(function NavigationMenuPort props: NavigationMenuPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const { mounted } = useNavigationMenuRootContext(); @@ -25,16 +26,15 @@ export const NavigationMenuPortal = React.forwardRef(function NavigationMenuPort return ( - - {children} - + ); }); export namespace NavigationMenuPortal { - export interface Props extends FloatingPortal.Props { - children?: React.ReactNode; + export interface State {} + + export interface Props extends FloatingPortal.Props { /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false @@ -43,6 +43,6 @@ export namespace NavigationMenuPortal { /** * A parent element to render the portal element into. */ - container?: FloatingPortal.Props['container']; + container?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/popover/portal/PopoverPortal.test.tsx b/packages/react/src/popover/portal/PopoverPortal.test.tsx new file mode 100644 index 0000000000..472ed2a6be --- /dev/null +++ b/packages/react/src/popover/portal/PopoverPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Popover } from '@base-ui-components/react/popover'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/popover/portal/PopoverPortal.tsx b/packages/react/src/popover/portal/PopoverPortal.tsx index 5aa250ebe0..ae6f21247d 100644 --- a/packages/react/src/popover/portal/PopoverPortal.tsx +++ b/packages/react/src/popover/portal/PopoverPortal.tsx @@ -7,6 +7,7 @@ import { PopoverPortalContext } from './PopoverPortalContext'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Popover](https://base-ui.com/react/components/popover) */ @@ -14,7 +15,7 @@ export const PopoverPortal = React.forwardRef(function PopoverPortal( props: PopoverPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const { mounted } = usePopoverRootContext(); @@ -25,23 +26,19 @@ export const PopoverPortal = React.forwardRef(function PopoverPortal( return ( - - {children} - + ); }); export namespace PopoverPortal { - export interface Props extends FloatingPortal.Props { + export interface State {} + + export interface Props extends FloatingPortal.Props { /** * 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?: FloatingPortal.Props['container']; } } diff --git a/packages/react/src/preview-card/portal/PreviewCardPortal.test.tsx b/packages/react/src/preview-card/portal/PreviewCardPortal.test.tsx new file mode 100644 index 0000000000..9a83dbd2c9 --- /dev/null +++ b/packages/react/src/preview-card/portal/PreviewCardPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { PreviewCard } from '@base-ui-components/react/preview-card'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/preview-card/portal/PreviewCardPortal.tsx b/packages/react/src/preview-card/portal/PreviewCardPortal.tsx index bddb43f519..8bee0efce7 100644 --- a/packages/react/src/preview-card/portal/PreviewCardPortal.tsx +++ b/packages/react/src/preview-card/portal/PreviewCardPortal.tsx @@ -7,6 +7,7 @@ import { FloatingPortalLite } from '../../utils/FloatingPortalLite'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Preview Card](https://base-ui.com/react/components/preview-card) */ @@ -14,7 +15,7 @@ export const PreviewCardPortal = React.forwardRef(function PreviewCardPortal( props: PreviewCardPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const { mounted } = usePreviewCardRootContext(); @@ -25,23 +26,19 @@ export const PreviewCardPortal = React.forwardRef(function PreviewCardPortal( return ( - - {children} - + ); }); export namespace PreviewCardPortal { - export interface Props extends FloatingPortalLite.Props { + export interface State {} + + export interface Props extends FloatingPortalLite.Props { /** * 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?: FloatingPortalLite.Props['container']; } } diff --git a/packages/react/src/select/portal/SelectPortal.test.tsx b/packages/react/src/select/portal/SelectPortal.test.tsx new file mode 100644 index 0000000000..0bbd5a10ec --- /dev/null +++ b/packages/react/src/select/portal/SelectPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Select } from '@base-ui-components/react/select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/select/portal/SelectPortal.tsx b/packages/react/src/select/portal/SelectPortal.tsx index 33e9934379..5dfc580de7 100644 --- a/packages/react/src/select/portal/SelectPortal.tsx +++ b/packages/react/src/select/portal/SelectPortal.tsx @@ -9,15 +9,14 @@ import { selectors } from '../store'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Select](https://base-ui.com/react/components/select) */ export const SelectPortal = React.forwardRef(function SelectPortal( - props: SelectPortal.Props, + portalProps: SelectPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, ...portalProps } = props; - const { store } = useSelectRootContext(); const mounted = useStore(store, selectors.mounted); const forceMount = useStore(store, selectors.forceMount); @@ -29,18 +28,13 @@ export const SelectPortal = React.forwardRef(function SelectPortal( return ( - - {children} - + ); }); export namespace SelectPortal { - export interface Props extends FloatingPortal.Props { - /** - * A parent element to render the portal element into. - */ - container?: FloatingPortal.Props['container']; - } + export interface State {} + + export type Props = FloatingPortal.Props; } diff --git a/packages/react/src/toast/portal/ToastPortal.test.tsx b/packages/react/src/toast/portal/ToastPortal.test.tsx new file mode 100644 index 0000000000..b21f3444fc --- /dev/null +++ b/packages/react/src/toast/portal/ToastPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Toast } from '@base-ui-components/react/toast'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render(node); + }, + })); +}); diff --git a/packages/react/src/toast/portal/ToastPortal.tsx b/packages/react/src/toast/portal/ToastPortal.tsx index 9cd25bc0d7..656c15a2cc 100644 --- a/packages/react/src/toast/portal/ToastPortal.tsx +++ b/packages/react/src/toast/portal/ToastPortal.tsx @@ -4,16 +4,14 @@ import { FloatingPortalLite } from '../../utils/FloatingPortalLite'; /** * A portal element that moves the viewport to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Toast](https://base-ui.com/react/components/toast) */ export const ToastPortal = FloatingPortalLite; export namespace ToastPortal { - export interface Props extends FloatingPortalLite.Props { - /** - * A parent element to render the portal element into. - */ - container?: FloatingPortalLite.Props['container']; - } + export interface State {} + + export type Props = FloatingPortalLite.Props; } diff --git a/packages/react/src/tooltip/portal/TooltipPortal.test.tsx b/packages/react/src/tooltip/portal/TooltipPortal.test.tsx new file mode 100644 index 0000000000..0f599bbee4 --- /dev/null +++ b/packages/react/src/tooltip/portal/TooltipPortal.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Tooltip } from '@base-ui-components/react/tooltip'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + })); +}); diff --git a/packages/react/src/tooltip/portal/TooltipPortal.tsx b/packages/react/src/tooltip/portal/TooltipPortal.tsx index 757b3e1c4f..28c2049510 100644 --- a/packages/react/src/tooltip/portal/TooltipPortal.tsx +++ b/packages/react/src/tooltip/portal/TooltipPortal.tsx @@ -7,6 +7,7 @@ import { FloatingPortalLite } from '../../utils/FloatingPortalLite'; /** * A portal element that moves the popup to a different part of the DOM. * By default, the portal element is appended to ``. + * Renders a `
` element. * * Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip) */ @@ -14,7 +15,7 @@ export const TooltipPortal = React.forwardRef(function TooltipPortal( props: TooltipPortal.Props, forwardedRef: React.ForwardedRef, ) { - const { children, keepMounted = false, ...portalProps } = props; + const { keepMounted = false, ...portalProps } = props; const { mounted } = useTooltipRootContext(); @@ -25,23 +26,19 @@ export const TooltipPortal = React.forwardRef(function TooltipPortal( return ( - - {children} - + ); }); export namespace TooltipPortal { - export interface Props extends FloatingPortalLite.Props { + export interface State {} + + export interface Props extends FloatingPortalLite.Props { /** * 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?: FloatingPortalLite.Props['container']; } } diff --git a/packages/react/src/utils/FloatingPortalLite.tsx b/packages/react/src/utils/FloatingPortalLite.tsx index a04a71216a..d254af0bb0 100644 --- a/packages/react/src/utils/FloatingPortalLite.tsx +++ b/packages/react/src/utils/FloatingPortalLite.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { useFloatingPortalNode, type FloatingPortal } from '../floating-ui-react'; -import type { BaseUIComponentProps } from '../utils/types'; /** * `FloatingPortal` includes tabbable logic handling for focus management. @@ -10,12 +9,10 @@ import type { BaseUIComponentProps } from '../utils/types'; * @internal */ export const FloatingPortalLite = React.forwardRef(function FloatingPortalLite( - props: FloatingPortalLite.Props, + componentProps: FloatingPortalLite.Props, forwardedRef: React.ForwardedRef, ) { - const { children, container, className, render, ...elementProps } = props; - - const componentProps = React.useMemo(() => ({ className, render }), [className, render]); + const { children, container, className, render, ...elementProps } = componentProps; const { portalNode, portalSubtree } = useFloatingPortalNode({ container, @@ -37,11 +34,5 @@ export const FloatingPortalLite = React.forwardRef(function FloatingPortalLite( }); export namespace FloatingPortalLite { - export interface State {} - - export interface Props - extends BaseUIComponentProps<'div', State, React.HTMLAttributes> { - children?: React.ReactNode; - container?: FloatingPortal.Props['container']; - } + export type Props = FloatingPortal.Props; } From 1183e7a2e13dd7ac75b66d5e93125e5176d3c1d1 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 7 Oct 2025 15:44:29 +1100 Subject: [PATCH 6/6] lint --- .../src/floating-ui-react/components/FloatingPortal.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx index 5b8e160cfb..77bb29098e 100644 --- a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx @@ -150,7 +150,7 @@ export function useFloatingPortalNode( * @internal */ export const FloatingPortal = React.forwardRef(function FloatingPortal( - componentProps: FloatingPortal.Props, + componentProps: FloatingPortal.Props & { renderGuards?: boolean }, forwardedRef: React.ForwardedRef, ) { const { children, container, className, render, renderGuards, ...elementProps } = componentProps; @@ -172,9 +172,10 @@ export const FloatingPortal = React.forwardRef(function FloatingPortal( const modal = focusManagerState?.modal; const open = focusManagerState?.open; - typeof renderGuards === 'boolean' - ? renderGuards - : !!focusManagerState && !focusManagerState.modal && focusManagerState.open && !!portalNode; + const shouldRenderGuards = + typeof renderGuards === 'boolean' + ? renderGuards + : !!focusManagerState && !focusManagerState.modal && focusManagerState.open && !!portalNode; // https://codesandbox.io/s/tabbable-portal-f4tng?file=/src/TabbablePortal.tsx React.useEffect(() => {