Skip to content

Commit 7afd118

Browse files
Provide access to portalContainer of react-aria-components popover element (#5381)
* Expose portalContainer prop for modal, popover, and tooltip
1 parent dd8b226 commit 7afd118

File tree

6 files changed

+151
-11
lines changed

6 files changed

+151
-11
lines changed

packages/react-aria-components/src/Modal.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTrigger
2626
/**
2727
* Whether the modal is currently performing an exit animation.
2828
*/
29-
isExiting?: boolean
29+
isExiting?: boolean,
30+
/**
31+
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
32+
* @default document.body
33+
*/
34+
UNSTABLE_portalContainer?: Element
3035
}
3136

3237
interface InternalModalContextValue {
@@ -72,6 +77,7 @@ function Modal(props: ModalOverlayProps, ref: ForwardedRef<HTMLDivElement>) {
7277
children,
7378
isEntering,
7479
isExiting,
80+
UNSTABLE_portalContainer,
7581
...otherProps
7682
} = props;
7783

@@ -83,7 +89,8 @@ function Modal(props: ModalOverlayProps, ref: ForwardedRef<HTMLDivElement>) {
8389
defaultOpen={defaultOpen}
8490
onOpenChange={onOpenChange}
8591
isEntering={isEntering}
86-
isExiting={isExiting}>
92+
isExiting={isExiting}
93+
UNSTABLE_portalContainer={UNSTABLE_portalContainer}>
8794
<ModalContent {...otherProps} modalRef={ref}>
8895
{children}
8996
</ModalContent>
@@ -136,7 +143,7 @@ function ModalOverlayWithForwardRef(props: ModalOverlayProps, ref: ForwardedRef<
136143
*/
137144
export const ModalOverlay = /*#__PURE__*/ (forwardRef as forwardRefType)(ModalOverlayWithForwardRef);
138145

139-
function ModalOverlayInner(props: ModalOverlayInnerProps) {
146+
function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInnerProps) {
140147
let modalRef = props.modalRef;
141148
let {state} = props;
142149
let {modalProps, underlayProps} = useModalOverlay(props, state, modalRef);
@@ -159,7 +166,7 @@ function ModalOverlayInner(props: ModalOverlayInnerProps) {
159166
};
160167

161168
return (
162-
<Overlay isExiting={props.isExiting}>
169+
<Overlay isExiting={props.isExiting} portalContainer={UNSTABLE_portalContainer}>
163170
<div
164171
{...mergeProps(filterDOMProps(props as any), underlayProps)}
165172
{...renderProps}

packages/react-aria-components/src/Popover.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ export interface PopoverProps extends Omit<PositionProps, 'isOpen'>, Omit<AriaPo
3939
/**
4040
* Whether the popover is currently performing an exit animation.
4141
*/
42-
isExiting?: boolean
42+
isExiting?: boolean,
43+
/**
44+
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
45+
* @default document.body
46+
*/
47+
UNSTABLE_portalContainer?: Element
4348
}
4449

4550
export interface PopoverRenderProps {
@@ -114,10 +119,11 @@ interface PopoverInnerProps extends AriaPopoverProps, RenderProps<PopoverRenderP
114119
state: OverlayTriggerState,
115120
isEntering?: boolean,
116121
isExiting: boolean,
122+
UNSTABLE_portalContainer?: Element,
117123
trigger?: string
118124
}
119125

120-
function PopoverInner({state, isExiting, ...props}: PopoverInnerProps) {
126+
function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: PopoverInnerProps) {
121127
let {popoverProps, underlayProps, arrowProps, placement} = usePopover({
122128
...props,
123129
offset: props.offset ?? 8
@@ -139,7 +145,7 @@ function PopoverInner({state, isExiting, ...props}: PopoverInnerProps) {
139145
let style = {...renderProps.style, ...popoverProps.style};
140146

141147
return (
142-
<Overlay isExiting={isExiting}>
148+
<Overlay isExiting={isExiting} portalContainer={UNSTABLE_portalContainer}>
143149
{!props.isNonModal && state.isOpen && <div {...underlayProps} style={{position: 'fixed', inset: 0}} />}
144150
<div
145151
{...mergeProps(filterDOMProps(props as any), popoverProps)}

packages/react-aria-components/src/Tooltip.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ export interface TooltipProps extends PositionProps, OverlayTriggerProps, AriaLa
3636
/**
3737
* Whether the tooltip is currently performing an exit animation.
3838
*/
39-
isExiting?: boolean
39+
isExiting?: boolean,
40+
/**
41+
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
42+
* @default document.body
43+
*/
44+
UNSTABLE_portalContainer?: Element
4045
}
4146

4247
export interface TooltipRenderProps {
@@ -87,7 +92,7 @@ export function TooltipTrigger(props: TooltipTriggerComponentProps) {
8792
);
8893
}
8994

90-
function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
95+
function Tooltip({UNSTABLE_portalContainer, ...props}: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
9196
[props, ref] = useContextProps(props, ref, TooltipContext);
9297
let contextState = useContext(TooltipTriggerStateContext);
9398
let localState = useTooltipTriggerState(props);
@@ -98,7 +103,7 @@ function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
98103
}
99104

100105
return (
101-
<OverlayContainer>
106+
<OverlayContainer portalContainer={UNSTABLE_portalContainer}>
102107
<TooltipInner {...props} tooltipRef={ref} isExiting={isExiting} />
103108
</OverlayContainer>
104109
);

packages/react-aria-components/test/Dialog.test.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Button, Dialog, DialogTrigger, Heading, Modal, ModalOverlay, OverlayArrow, Popover} from '../';
13+
import {
14+
Button,
15+
Dialog,
16+
DialogTrigger,
17+
Heading,
18+
Modal,
19+
ModalOverlay,
20+
OverlayArrow,
21+
Popover
22+
} from '../';
1423
import {pointerMap, render, within} from '@react-spectrum/test-utils';
1524
import React from 'react';
1625
import userEvent from '@testing-library/user-event';
@@ -279,4 +288,41 @@ describe('Dialog', () => {
279288
rerender(<TestModal />);
280289
expect(modal).not.toBeInTheDocument();
281290
});
291+
292+
describe('portalContainer', () => {
293+
function InfoDialog(props) {
294+
return (
295+
<DialogTrigger>
296+
<Button>Delete…</Button>
297+
<Modal UNSTABLE_portalContainer={props.container} data-test="modal">
298+
<Dialog role="alertdialog" data-test="dialog">
299+
{({close}) => (
300+
<>
301+
<Heading slot="title">Alert</Heading>
302+
<Button onPress={close}>Close</Button>
303+
</>
304+
)}
305+
</Dialog>
306+
</Modal>
307+
</DialogTrigger>
308+
);
309+
}
310+
function App() {
311+
let [container, setContainer] = React.useState();
312+
return (
313+
<>
314+
<InfoDialog container={container} />
315+
<div ref={setContainer} data-testid="custom-container" />
316+
</>
317+
);
318+
}
319+
it('should render the tooltip in the portal container', async () => {
320+
let {getByRole, getByTestId} = render(<App />);
321+
let button = getByRole('button');
322+
await user.click(button);
323+
324+
expect(getByRole('alertdialog').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container'));
325+
await user.click(document.body);
326+
});
327+
});
282328
});

packages/react-aria-components/test/Popover.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,41 @@ describe('Popover', () => {
151151
rerender(<TestPopover />);
152152
expect(popover).not.toBeInTheDocument();
153153
});
154+
155+
describe('portalContainer', () => {
156+
function InfoPopover(props) {
157+
return (
158+
<DialogTrigger>
159+
<Button />
160+
<Popover UNSTABLE_portalContainer={props.container}>
161+
<OverlayArrow>
162+
<svg width={12} height={12}>
163+
<path d="M0 0,L6 6,L12 0" />
164+
</svg>
165+
</OverlayArrow>
166+
<Dialog>Popover</Dialog>
167+
</Popover>
168+
</DialogTrigger>
169+
);
170+
}
171+
function App() {
172+
let [container, setContainer] = React.useState();
173+
return (
174+
<>
175+
<InfoPopover container={container} />
176+
<div ref={setContainer} data-testid="custom-container" />
177+
</>
178+
);
179+
}
180+
it('should render the dialog in the portal container', async () => {
181+
let {getByRole, getByTestId} = render(
182+
<App />
183+
);
184+
185+
let button = getByRole('button');
186+
await user.click(button);
187+
188+
expect(getByRole('dialog').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container'));
189+
});
190+
});
154191
});

packages/react-aria-components/test/Tooltip.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,43 @@ describe('Tooltip', () => {
115115
rerender(<TestTooltip />);
116116
expect(tooltip).not.toBeInTheDocument();
117117
});
118+
describe('portalContainer', () => {
119+
function InfoTooltip(props) {
120+
return (
121+
<TooltipTrigger delay={0}>
122+
<Button><span aria-hidden="true">✏️</span></Button>
123+
<Tooltip UNSTABLE_portalContainer={props.container} data-test="tooltip" {...props}>
124+
<OverlayArrow>
125+
<svg width={8} height={8}>
126+
<path d="M0 0,L4 4,L8 0" />
127+
</svg>
128+
</OverlayArrow>
129+
Edit
130+
</Tooltip>
131+
</TooltipTrigger>
132+
);
133+
}
134+
function App() {
135+
let [container, setContainer] = React.useState();
136+
return (
137+
<>
138+
<InfoTooltip container={container} />
139+
<div ref={setContainer} data-testid="custom-container" />
140+
</>
141+
);
142+
}
143+
it('should render the tooltip in the portal container', async () => {
144+
let {getByRole, getByTestId} = render(<App />);
145+
let button = getByRole('button');
146+
147+
fireEvent.mouseMove(document.body);
148+
await user.hover(button);
149+
act(() => jest.runAllTimers());
150+
151+
expect(getByRole('tooltip').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container'));
152+
153+
await user.unhover(button);
154+
act(() => jest.runAllTimers());
155+
});
156+
});
118157
});

0 commit comments

Comments
 (0)