Skip to content
Merged
15 changes: 11 additions & 4 deletions packages/react-aria-components/src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTrigger
/**
* Whether the modal is currently performing an exit animation.
*/
isExiting?: boolean
isExiting?: boolean,
/**
* 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
*/
UNSTABLE_portalContainer?: Element
}

interface InternalModalContextValue {
Expand Down Expand Up @@ -72,6 +77,7 @@ function Modal(props: ModalOverlayProps, ref: ForwardedRef<HTMLDivElement>) {
children,
isEntering,
isExiting,
UNSTABLE_portalContainer,
...otherProps
} = props;

Expand All @@ -83,7 +89,8 @@ function Modal(props: ModalOverlayProps, ref: ForwardedRef<HTMLDivElement>) {
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
isEntering={isEntering}
isExiting={isExiting}>
isExiting={isExiting}
UNSTABLE_portalContainer={UNSTABLE_portalContainer}>
<ModalContent {...otherProps} modalRef={ref}>
{children}
</ModalContent>
Expand Down Expand Up @@ -136,7 +143,7 @@ function ModalOverlayWithForwardRef(props: ModalOverlayProps, ref: ForwardedRef<
*/
export const ModalOverlay = /*#__PURE__*/ (forwardRef as forwardRefType)(ModalOverlayWithForwardRef);

function ModalOverlayInner(props: ModalOverlayInnerProps) {
function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInnerProps) {
let modalRef = props.modalRef;
let {state} = props;
let {modalProps, underlayProps} = useModalOverlay(props, state, modalRef);
Expand All @@ -159,7 +166,7 @@ function ModalOverlayInner(props: ModalOverlayInnerProps) {
};

return (
<Overlay isExiting={props.isExiting}>
<Overlay isExiting={props.isExiting} portalContainer={UNSTABLE_portalContainer}>
<div
{...mergeProps(filterDOMProps(props as any), underlayProps)}
{...renderProps}
Expand Down
12 changes: 9 additions & 3 deletions packages/react-aria-components/src/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ export interface PopoverProps extends Omit<PositionProps, 'isOpen'>, Omit<AriaPo
/**
* Whether the popover is currently performing an exit animation.
*/
isExiting?: boolean
isExiting?: boolean,
/**
* 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
*/
UNSTABLE_portalContainer?: Element
}

export interface PopoverRenderProps {
Expand Down Expand Up @@ -114,10 +119,11 @@ interface PopoverInnerProps extends AriaPopoverProps, RenderProps<PopoverRenderP
state: OverlayTriggerState,
isEntering?: boolean,
isExiting: boolean,
UNSTABLE_portalContainer?: Element,
trigger?: string
}

function PopoverInner({state, isExiting, ...props}: PopoverInnerProps) {
function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: PopoverInnerProps) {
let {popoverProps, underlayProps, arrowProps, placement} = usePopover({
...props,
offset: props.offset ?? 8
Expand All @@ -139,7 +145,7 @@ function PopoverInner({state, isExiting, ...props}: PopoverInnerProps) {
let style = {...renderProps.style, ...popoverProps.style};

return (
<Overlay isExiting={isExiting}>
<Overlay isExiting={isExiting} portalContainer={UNSTABLE_portalContainer}>
{!props.isNonModal && state.isOpen && <div {...underlayProps} style={{position: 'fixed', inset: 0}} />}
<div
{...mergeProps(filterDOMProps(props as any), popoverProps)}
Expand Down
11 changes: 8 additions & 3 deletions packages/react-aria-components/src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export interface TooltipProps extends PositionProps, OverlayTriggerProps, AriaLa
/**
* Whether the tooltip is currently performing an exit animation.
*/
isExiting?: boolean
isExiting?: boolean,
/**
* 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
*/
UNSTABLE_portalContainer?: Element
}

export interface TooltipRenderProps {
Expand Down Expand Up @@ -87,7 +92,7 @@ export function TooltipTrigger(props: TooltipTriggerComponentProps) {
);
}

function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
function Tooltip({UNSTABLE_portalContainer, ...props}: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, TooltipContext);
let contextState = useContext(TooltipTriggerStateContext);
let localState = useTooltipTriggerState(props);
Expand All @@ -98,7 +103,7 @@ function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
}

return (
<OverlayContainer>
<OverlayContainer portalContainer={UNSTABLE_portalContainer}>
<TooltipInner {...props} tooltipRef={ref} isExiting={isExiting} />
</OverlayContainer>
);
Expand Down
48 changes: 47 additions & 1 deletion packages/react-aria-components/test/Dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
* governing permissions and limitations under the License.
*/

import {Button, Dialog, DialogTrigger, Heading, Modal, ModalOverlay, OverlayArrow, Popover} from '../';
import {
Button,
Dialog,
DialogTrigger,
Heading,
Modal,
ModalOverlay,
OverlayArrow,
Popover
} from '../';
import {pointerMap, render, within} from '@react-spectrum/test-utils';
import React from 'react';
import userEvent from '@testing-library/user-event';
Expand Down Expand Up @@ -279,4 +288,41 @@ describe('Dialog', () => {
rerender(<TestModal />);
expect(modal).not.toBeInTheDocument();
});

describe('portalContainer', () => {
function InfoDialog(props) {
return (
<DialogTrigger>
<Button>Delete…</Button>
<Modal UNSTABLE_portalContainer={props.container} data-test="modal">
<Dialog role="alertdialog" data-test="dialog">
{({close}) => (
<>
<Heading slot="title">Alert</Heading>
<Button onPress={close}>Close</Button>
</>
)}
</Dialog>
</Modal>
</DialogTrigger>
);
}
function App() {
let [container, setContainer] = React.useState();
return (
<>
<InfoDialog container={container} />
<div ref={setContainer} data-testid="custom-container" />
</>
);
}
it('should render the tooltip in the portal container', async () => {
let {getByRole, getByTestId} = render(<App />);
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);
});
});
});
37 changes: 37 additions & 0 deletions packages/react-aria-components/test/Popover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,41 @@ describe('Popover', () => {
rerender(<TestPopover />);
expect(popover).not.toBeInTheDocument();
});

describe('portalContainer', () => {
function InfoPopover(props) {
return (
<DialogTrigger>
<Button />
<Popover UNSTABLE_portalContainer={props.container}>
<OverlayArrow>
<svg width={12} height={12}>
<path d="M0 0,L6 6,L12 0" />
</svg>
</OverlayArrow>
<Dialog>Popover</Dialog>
</Popover>
</DialogTrigger>
);
}
function App() {
let [container, setContainer] = React.useState();
return (
<>
<InfoPopover container={container} />
<div ref={setContainer} data-testid="custom-container" />
</>
);
}
it('should render the dialog in the portal container', async () => {
let {getByRole, getByTestId} = render(
<App />
);

let button = getByRole('button');
await user.click(button);

expect(getByRole('dialog').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container'));
});
});
});
39 changes: 39 additions & 0 deletions packages/react-aria-components/test/Tooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,43 @@ describe('Tooltip', () => {
rerender(<TestTooltip />);
expect(tooltip).not.toBeInTheDocument();
});
describe('portalContainer', () => {
function InfoTooltip(props) {
return (
<TooltipTrigger delay={0}>
<Button><span aria-hidden="true">✏️</span></Button>
<Tooltip UNSTABLE_portalContainer={props.container} data-test="tooltip" {...props}>
<OverlayArrow>
<svg width={8} height={8}>
<path d="M0 0,L4 4,L8 0" />
</svg>
</OverlayArrow>
Edit
</Tooltip>
</TooltipTrigger>
);
}
function App() {
let [container, setContainer] = React.useState();
return (
<>
<InfoTooltip container={container} />
<div ref={setContainer} data-testid="custom-container" />
</>
);
}
it('should render the tooltip in the portal container', async () => {
let {getByRole, getByTestId} = render(<App />);
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());
});
});
});