diff --git a/packages/@react-aria/overlays/docs/PortalProvider.mdx b/packages/@react-aria/overlays/docs/PortalProvider.mdx new file mode 100644 index 00000000000..69b63e72fc8 --- /dev/null +++ b/packages/@react-aria/overlays/docs/PortalProvider.mdx @@ -0,0 +1,138 @@ +{/* Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +import {Layout} from '@react-spectrum/docs'; +export default Layout; + +import docs from 'docs:@react-aria/overlays'; +import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs'; +import packageData from '@react-aria/overlays/package.json'; + +--- +category: Utilities +keywords: [overlays, portals] +--- + +# PortalProvider + +{docs.exports.UNSAFE_PortalProvider.description} + + + +## Introduction + +`UNSAFE_PortalProvider` is a utility wrapper component that can be used to set where components like +Modals, Popovers, Toasts, and Tooltips will portal their overlay element to. This is typically used when +your app is already portalling other elements to a location other than the `document.body` and thus requires +your React Aria components to send their overlays to the same container. + +Please note that `UNSAFE_PortalProvider` is considered `UNSAFE` because it is an escape hatch, and there are +many places that an application could portal to. Not all of them will work, either with styling, accessibility, +or for a variety of other reasons. Typically, it is best to portal to the root of the entire application, e.g. the `body` element, +outside of any possible overflow or stacking contexts. We envision `UNSAFE_PortalProvider` being used to group all of the portalled +elements into a single container at the root of the app or to control the order of children of the `body` element, but you may have use cases +that need to do otherwise. + +## Props + + + +## Example + +The example below shows how you can use `UNSAFE_PortalProvider` to portal your Toasts to an arbitrary container. Note that +the Toast in this example is taken directly from the [React Aria Components Toast documentation](Toast.html#example), please visit that page for +a detailed explanation of its implementation. + +```tsx import +import {UNSTABLE_ToastRegion as ToastRegion, UNSTABLE_Toast as Toast, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastContent as ToastContent, Button, Text} from 'react-aria-components'; + + +// Define the type for your toast content. +interface MyToastContent { + title: string, + description?: string +} + +// Create a global ToastQueue. +const queue = new ToastQueue(); + +function MyToastRegion() { + return ( + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description} + + + + )} + + + ); +} +``` + +```tsx example +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; + +// See the above Toast docs link for the ToastRegion implementation +function App() { + let container = React.useRef(null); + return ( + <> + container.current}> + + + +
+ Toasts are portalled here! +
+ + ); +} + + +``` + +```css hidden +@import '../../../react-aria-components/docs/Button.mdx' layer(button); +@import '../../../react-aria-components/docs/Toast.mdx' layer(toast); +@import "@react-aria/example-theme"; + +.react-aria-ToastRegion { + position: unset; +} +``` + +## Contexts + +The `getContainer` set by the nearest PortalProvider can be accessed by calling `useUNSAFE_PortalContext`. This can be +used by custom overlay components to ensure that they are also being consistently portalled throughout your app. + + + +```tsx +import {useUNSAFE_PortalContext} from '@react-aria/overlays'; + +function MyOverlay(props) { + let {children} = props; + let {getContainer} = useUNSAFE_PortalContext(); + return ReactDOM.createPortal(children, getContainer()); +} +``` diff --git a/packages/@react-aria/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx index 61711422c25..ee818bcefc6 100644 --- a/packages/@react-aria/overlays/src/Overlay.tsx +++ b/packages/@react-aria/overlays/src/Overlay.tsx @@ -16,12 +16,13 @@ import React, {ReactNode, useContext, useMemo, useState} from 'react'; import ReactDOM from 'react-dom'; import {useIsSSR} from '@react-aria/ssr'; import {useLayoutEffect} from '@react-aria/utils'; -import {useUNSTABLE_PortalContext} from './PortalProvider'; +import {useUNSAFE_PortalContext} from './PortalProvider'; export interface OverlayProps { /** * The container element in which the overlay portal will be placed. * @default document.body + * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead. */ portalContainer?: Element, /** The overlay to render in the portal. */ @@ -55,8 +56,8 @@ export function Overlay(props: OverlayProps): ReactNode | null { let [contain, setContain] = useState(false); let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]); - let {getContainer} = useUNSTABLE_PortalContext(); - if (!props.portalContainer && getContainer) { + let {getContainer} = useUNSAFE_PortalContext(); + if (!props.portalContainer && getContainer) { portalContainer = getContainer(); } diff --git a/packages/@react-aria/overlays/src/PortalProvider.tsx b/packages/@react-aria/overlays/src/PortalProvider.tsx index ab0e59a8c6e..8721a00df5f 100644 --- a/packages/@react-aria/overlays/src/PortalProvider.tsx +++ b/packages/@react-aria/overlays/src/PortalProvider.tsx @@ -13,15 +13,22 @@ import React, {createContext, ReactNode, useContext} from 'react'; export interface PortalProviderProps { - /* Should return the element where we should portal to. Can clear the context by passing null. */ - getContainer?: () => HTMLElement | null + /** Should return the element where we should portal to. Can clear the context by passing null. */ + getContainer?: () => HTMLElement | null, + /** The content of the PortalProvider. Should contain all children that want to portal their overlays to the element returned by the provided `getContainer()`. */ + children: ReactNode } -export const PortalContext = createContext({}); +export interface PortalProviderContextValue extends Omit{}; -export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ReactNode}): ReactNode { +export const PortalContext = createContext({}); + +/** + * Sets the portal container for all overlay elements rendered by its children. + */ +export function UNSAFE_PortalProvider(props: PortalProviderProps): ReactNode { let {getContainer} = props; - let {getContainer: ctxGetContainer} = useUNSTABLE_PortalContext(); + let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext(); return ( {props.children} @@ -29,6 +36,6 @@ export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ); } -export function useUNSTABLE_PortalContext(): PortalProviderProps { +export function useUNSAFE_PortalContext(): PortalProviderContextValue { return useContext(PortalContext) ?? {}; } diff --git a/packages/@react-aria/overlays/src/index.ts b/packages/@react-aria/overlays/src/index.ts index d73f2def7eb..cf37e048e7d 100644 --- a/packages/@react-aria/overlays/src/index.ts +++ b/packages/@react-aria/overlays/src/index.ts @@ -19,7 +19,7 @@ export {ariaHideOutside} from './ariaHideOutside'; export {usePopover} from './usePopover'; export {useModalOverlay} from './useModalOverlay'; export {Overlay, useOverlayFocusContain} from './Overlay'; -export {UNSTABLE_PortalProvider, useUNSTABLE_PortalContext} from './PortalProvider'; +export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider'; export type {AriaPositionProps, PositionAria} from './useOverlayPosition'; export type {AriaOverlayProps, OverlayAria} from './useOverlay'; @@ -30,3 +30,4 @@ export type {AriaPopoverProps, PopoverAria} from './usePopover'; export type {AriaModalOverlayProps, ModalOverlayAria} from './useModalOverlay'; export type {OverlayProps} from './Overlay'; export type {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; +export type {PortalProviderProps, PortalProviderContextValue} from './PortalProvider'; diff --git a/packages/@react-aria/overlays/src/useModal.tsx b/packages/@react-aria/overlays/src/useModal.tsx index 5360a93cad8..5a5e8188b8a 100644 --- a/packages/@react-aria/overlays/src/useModal.tsx +++ b/packages/@react-aria/overlays/src/useModal.tsx @@ -14,6 +14,7 @@ import {DOMAttributes} from '@react-types/shared'; import React, {AriaAttributes, ReactNode, useContext, useEffect, useMemo, useState} from 'react'; import ReactDOM from 'react-dom'; import {useIsSSR} from '@react-aria/ssr'; +import {useUNSAFE_PortalContext} from './PortalProvider'; export interface ModalProviderProps extends DOMAttributes { children: ReactNode @@ -112,6 +113,7 @@ export interface OverlayContainerProps extends ModalProviderProps { /** * The container element in which the overlay portal will be placed. * @default document.body + * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead. */ portalContainer?: Element } @@ -126,6 +128,10 @@ export interface OverlayContainerProps extends ModalProviderProps { export function OverlayContainer(props: OverlayContainerProps): React.ReactPortal | null { let isSSR = useIsSSR(); let {portalContainer = isSSR ? null : document.body, ...rest} = props; + let {getContainer} = useUNSAFE_PortalContext(); + if (!props.portalContainer && getContainer) { + portalContainer = getContainer(); + } React.useEffect(() => { if (portalContainer?.closest('[data-overlay-container]')) { diff --git a/packages/@react-spectrum/dialog/test/DialogContainer.test.js b/packages/@react-spectrum/dialog/test/DialogContainer.test.js index 972055d8105..4fe0a03c01d 100644 --- a/packages/@react-spectrum/dialog/test/DialogContainer.test.js +++ b/packages/@react-spectrum/dialog/test/DialogContainer.test.js @@ -21,7 +21,7 @@ import {Heading, Text} from '@react-spectrum/text'; import {Provider} from '@react-spectrum/provider'; import React, {useRef, useState} from 'react'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; describe('DialogContainer', function () { @@ -254,13 +254,13 @@ describe('DialogContainer', function () { return ( setOpen(true)}>Open dialog - container.current}> + container.current}> setOpen(false)} {...props}> {isOpen && } - +
); diff --git a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js index 15ee46ec88f..f0eb6ff8efc 100644 --- a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js +++ b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js @@ -21,7 +21,7 @@ import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {TextField} from '@react-spectrum/textfield'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; @@ -1031,12 +1031,12 @@ describe('DialogTrigger', function () { let {container} = props; return ( - container.current}> + container.current}> Trigger contents - + ); } diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index 2e6752da3b0..827aa598dd1 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -29,7 +29,7 @@ import {Link} from '@react-spectrum/link'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -735,7 +735,7 @@ describe('MenuTrigger', function () { function InfoMenu(props) { return ( - props.container.current}> + props.container.current}> @@ -744,7 +744,7 @@ describe('MenuTrigger', function () { Three - + ); } diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index c80cb5b26c0..defc24868ea 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -1,4 +1,4 @@ - + import {classNames} from '@react-spectrum/utils'; import {ColumnSize} from '@react-types/table'; import eCursor from 'bundle-text:./cursors/Cur_MoveToRight_9_9.svg'; @@ -16,7 +16,7 @@ import {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext, useVirtualizerContext} from './TableViewBase'; -import {useUNSTABLE_PortalContext} from '@react-aria/overlays'; +import {useUNSAFE_PortalContext} from '@react-aria/overlays'; // @ts-ignore import wCursor from 'bundle-text:./cursors/Cur_MoveToLeft_9_9.svg'; @@ -132,6 +132,6 @@ export const Resizer = React.forwardRef(function Resizer(props: ResizerProps< function CursorOverlay(props) { let {show, children} = props; - let {getContainer} = useUNSTABLE_PortalContext(); + let {getContainer} = useUNSAFE_PortalContext(); return show ? ReactDOM.createPortal(children, getContainer?.() ?? document.body) : null; } diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 7a36427b394..cddb75e8ea6 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -26,7 +26,7 @@ import {resizingTests} from '@react-aria/table/test/tableResizingTests'; import {Scale} from '@react-types/provider'; import {setInteractionModality} from '@react-aria/interactions'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; let columns = [ @@ -1048,7 +1048,7 @@ describe('TableViewSizing', function () { let Example = (props) => { let container = useRef(null); return ( - container.current}> + container.current}> Foo @@ -1064,7 +1064,7 @@ describe('TableViewSizing', function () {
- + ); }; let customPortalRender = (props) => render(); diff --git a/packages/@react-spectrum/toast/src/Toaster.tsx b/packages/@react-spectrum/toast/src/Toaster.tsx index 356fc97fc4c..0fb2ae7ad2e 100644 --- a/packages/@react-spectrum/toast/src/Toaster.tsx +++ b/packages/@react-spectrum/toast/src/Toaster.tsx @@ -20,7 +20,7 @@ import ReactDOM from 'react-dom'; import toastContainerStyles from './toastContainer.css'; import type {ToastPlacement} from './ToastContainer'; import {ToastState} from '@react-stately/toast'; -import {useUNSTABLE_PortalContext} from '@react-aria/overlays'; +import {useUNSAFE_PortalContext} from '@react-aria/overlays'; interface ToastContainerProps extends AriaToastRegionProps { children: ReactNode, @@ -39,7 +39,7 @@ export function Toaster(props: ToastContainerProps): ReactElement { let ref = useRef(null); let {regionProps} = useToastRegion(props, state, ref); let {focusProps, isFocusVisible} = useFocusRing(); - let {getContainer} = useUNSTABLE_PortalContext(); + let {getContainer} = useUNSAFE_PortalContext(); let [position, placement] = useMemo(() => { let [pos = 'bottom', place = 'center'] = props.placement?.split(' ') || []; diff --git a/packages/@react-spectrum/toast/stories/Toast.stories.tsx b/packages/@react-spectrum/toast/stories/Toast.stories.tsx index 9e7d0cb26e1..a4e1e697151 100644 --- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx +++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx @@ -21,8 +21,8 @@ import {Heading} from '@react-spectrum/text'; import React, {SyntheticEvent, useEffect, useMemo, useRef, useState} from 'react'; import {SpectrumToastOptions, ToastPlacement} from '../src/ToastContainer'; import {ToastContainer, ToastQueue} from '../'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {UNSTABLE_createLandmarkController, useLandmark} from '@react-aria/landmark'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; export default { title: 'Toast', @@ -384,11 +384,11 @@ function FullscreenApp(props) { }, []); return (
- ref.current}> + ref.current}> Enter fullscreen {isFullscreen && } - + {!isFullscreen && }
); diff --git a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js index c6be0220a08..86eb44719c7 100644 --- a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js +++ b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js @@ -16,7 +16,7 @@ import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import {Tooltip, TooltipTrigger} from '../'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; // Sync with useTooltipTriggerState.ts @@ -1003,14 +1003,14 @@ describe('TooltipTrigger', function () { describe('portalContainer', () => { function InfoTooltip(props) { return ( - props.container.current}> + props.container.current}>
hello
-
+ ); } @@ -1049,24 +1049,24 @@ describe('TooltipTrigger', function () { describe('portalContainer overwrite', () => { function InfoTooltip(props) { return ( - +
hello
-
+ ); } function App() { let container = React.useRef(null); return ( <> - container.current}> + container.current}>
- + ); } diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index aef626ec8c9..6b1cd161e77 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -45,6 +45,7 @@ "@react-aria/focus": "^3.20.1", "@react-aria/interactions": "^3.24.1", "@react-aria/live-announcer": "^3.4.1", + "@react-aria/overlays": "^3.26.1", "@react-aria/ssr": "^3.9.7", "@react-aria/toolbar": "3.0.0-beta.14", "@react-aria/utils": "^3.28.1", diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index 713d458ecb5..e76520fc10b 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -30,6 +30,7 @@ export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTrigger /** * The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to. * @default document.body + * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead. */ UNSTABLE_portalContainer?: Element } diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index d5f10fdf8b4..33946892d9c 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -46,6 +46,7 @@ export interface PopoverProps extends Omit, Omit | null>(null); @@ -42,12 +43,7 @@ export interface ToastRegionProps extends AriaToastRegionProps, StyleRenderPr /** The queue of toasts to display. */ queue: ToastQueue, /** A function to render each toast. */ - children: (renderProps: {toast: QueuedToast}) => ReactElement, - /** - * The container element in which the toast region portal will be placed. - * @default document.body - */ - portalContainer?: Element + children: (renderProps: {toast: QueuedToast}) => ReactElement } /** @@ -71,7 +67,14 @@ export const ToastRegion = /*#__PURE__*/ (forwardRef as forwardRefType)(function } }); - let {portalContainer = isSSR ? null : document.body} = props; + let portalContainer; + let {getContainer} = useUNSAFE_PortalContext(); + if (!isSSR) { + portalContainer = document.body; + if (getContainer) { + portalContainer = getContainer(); + } + } let region = ( diff --git a/packages/react-aria-components/src/Tooltip.tsx b/packages/react-aria-components/src/Tooltip.tsx index 18eb46f978d..0e4302da8b0 100644 --- a/packages/react-aria-components/src/Tooltip.tsx +++ b/packages/react-aria-components/src/Tooltip.tsx @@ -41,6 +41,7 @@ export interface TooltipProps extends PositionProps, Pick { @@ -302,6 +303,46 @@ describe('Dialog', () => { expect(modal).not.toBeInTheDocument(); }); + describe('portalProvider', () => { + function InfoDialog() { + return ( + + + + + {({close}) => ( + <> + Alert + + + )} + + + + ); + } + function App() { + let container = useRef(null); + return ( + <> + container.current}> + + +
+ + ); + } + it('should render the dialog in the portal container provided by the PortalProvider', async () => { + let {getByRole, getByTestId} = render(); + let button = getByRole('button'); + await user.click(button); + + expect(getByRole('alertdialog').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container')); + await user.click(document.body); + }); + }); + + // TODO: delete this test when we get rid of the deprecated prop describe('portalContainer', () => { function InfoDialog(props) { return ( @@ -329,7 +370,7 @@ describe('Dialog', () => { ); } - it('should render the tooltip in the portal container', async () => { + it('should render the dialog in the portal container', async () => { let {getByRole, getByTestId} = render(); let button = getByRole('button'); await user.click(button); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index 79b84f8f525..fc30b7796e8 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -15,7 +15,7 @@ import {AriaMenuTests} from './AriaMenu.test-util'; import {Button, Collection, Header, Heading, Input, Keyboard, Label, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text, TextField} from '..'; import React, {useState} from 'react'; import {Selection, SelectionMode} from '@react-types/shared'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -1336,7 +1336,7 @@ describe('Menu', () => { describe('portalContainer', () => { function InfoMenu(props) { return ( - props.container.current}> + props.container.current}> )} - + ); } function App() { - let [container, setContainer] = React.useState(); + let container = useRef(null); return ( <> - -
+ container.current}> + + +
); } diff --git a/packages/react-aria-components/test/Tooltip.test.js b/packages/react-aria-components/test/Tooltip.test.js index 91c16871787..249124a234b 100644 --- a/packages/react-aria-components/test/Tooltip.test.js +++ b/packages/react-aria-components/test/Tooltip.test.js @@ -12,7 +12,8 @@ import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {Button, Focusable, OverlayArrow, Pressable, Tooltip, TooltipTrigger} from 'react-aria-components'; -import React from 'react'; +import React, {useRef} from 'react'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; function TestTooltip(props) { @@ -168,6 +169,49 @@ describe('Tooltip', () => { expect(tooltip1).not.toBeVisible(); }); + describe('portalProvider', () => { + function InfoTooltip(props) { + return ( + + + + + + + + + Edit + + + ); + } + function App() { + let container = useRef(null); + return ( + <> + container.current}> + + +
+ + ); + } + it('should render the tooltip in the portal container provided by the PortalProvider', async () => { + let {getByRole, getByTestId} = render(); + let button = getByRole('button'); + + fireEvent.mouseMove(document.body); + await user.hover(button); + act(() => jest.runAllTimers()); + + expect(getByRole('tooltip').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container')); + + await user.unhover(button); + act(() => jest.runAllTimers()); + }); + }); + + // TODO: delete this test when we get rid of the deprecated prop describe('portalContainer', () => { function InfoTooltip(props) { return ( diff --git a/yarn.lock b/yarn.lock index b98821a6f65..fc03054ddbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29253,6 +29253,7 @@ __metadata: "@react-aria/focus": "npm:^3.20.1" "@react-aria/interactions": "npm:^3.24.1" "@react-aria/live-announcer": "npm:^3.4.1" + "@react-aria/overlays": "npm:^3.26.1" "@react-aria/ssr": "npm:^3.9.7" "@react-aria/toolbar": "npm:3.0.0-beta.14" "@react-aria/utils": "npm:^3.28.1"