Skip to content

Commit 8e571ae

Browse files
authored
Add getContainerRootElement prop to OverlayContainer (#1013)
1 parent d184457 commit 8e571ae

File tree

4 files changed

+193
-9
lines changed

4 files changed

+193
-9
lines changed

packages/@react-aria/overlays/src/useModal.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function useModalProvider(): ModalProviderAria {
8787
*/
8888
function OverlayContainerDOM(props: ModalProviderProps) {
8989
let {modalProviderProps} = useModalProvider();
90-
return <div {...props} {...modalProviderProps} />;
90+
return <div data-overlay-container {...props} {...modalProviderProps} />;
9191
}
9292

9393
/**
@@ -106,16 +106,28 @@ export function OverlayProvider(props: ModalProviderProps) {
106106
);
107107
}
108108

109+
interface OverlayContainerProps extends ModalProviderProps {
110+
portalContainer?: HTMLElement
111+
}
112+
109113
/**
110114
* A container for overlays like modals and popovers. Renders the overlay
111115
* into a Portal which is placed at the end of the document body.
112116
* Also ensures that the overlay is hidden from screen readers if a
113117
* nested modal is opened. Only the top-most modal or overlay should
114118
* be accessible at once.
115119
*/
116-
export function OverlayContainer(props: ModalProviderProps): React.ReactPortal {
117-
let contents = <OverlayProvider {...props} />;
118-
return ReactDOM.createPortal(contents, document.body);
120+
export function OverlayContainer(props: OverlayContainerProps): React.ReactPortal {
121+
let {portalContainer = document.body, ...rest} = props;
122+
123+
React.useEffect(() => {
124+
if (portalContainer.closest('[data-overlay-container]')) {
125+
throw new Error('An OverlayContainer must not be inside another container. Please change the portalContainer prop.');
126+
}
127+
}, [portalContainer]);
128+
129+
let contents = <OverlayProvider {...rest} />;
130+
return ReactDOM.createPortal(contents, portalContainer);
119131
}
120132

121133
interface ModalAriaProps extends HTMLAttributes<HTMLElement> {

packages/@react-aria/overlays/stories/UseOverlayPosition.stories.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
113
import {mergeProps} from '@react-aria/utils';
214
import {Placement} from '@react-types/overlays';
315
import * as React from 'react';
@@ -29,9 +41,9 @@ function Trigger(props: {
2941
});
3042

3143
let overlay = (
32-
<div
33-
ref={overlayRef}
34-
{...mergeProps(overlayProps, overlayPositionProps)}
44+
<div
45+
ref={overlayRef}
46+
{...mergeProps(overlayProps, overlayPositionProps)}
3547
style={{
3648
...overlayPositionProps.style,
3749
boxShadow: '0 0 4px 0 rgba(0,0,0,0.25)',
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {ActionButton} from '@react-spectrum/button';
14+
import {OverlayContainer, OverlayProvider, useModal} from '../src';
15+
import React, {useState} from 'react';
16+
import {storiesOf} from '@storybook/react';
17+
18+
19+
storiesOf('useModal', module)
20+
.add('default container', () => (
21+
<App />
22+
))
23+
.add('different container', () => (
24+
<App useAlternateContainer />
25+
));
26+
27+
function App(props) {
28+
let [showModal, setShowModal] = useState(false);
29+
return (
30+
<>
31+
<ActionButton onPress={() => setShowModal(prev => !prev)}>Toggle</ActionButton>
32+
<div id="alternateContainer" data-testid="alternate-container">
33+
<Example showModal={showModal} {...props}>The Modal</Example>
34+
</div>
35+
</>
36+
);
37+
}
38+
39+
function ModalDOM(props) {
40+
let {modalProps} = useModal();
41+
return <div data-testid={props.modalId || 'modal'} {...modalProps}>{props.children}</div>;
42+
}
43+
44+
function Modal(props) {
45+
return (
46+
<OverlayContainer portalContainer={props.container} data-testid={props.providerId || 'modal-provider'}>
47+
<ModalDOM modalId={props.modalId}>{props.children}</ModalDOM>
48+
</OverlayContainer>
49+
);
50+
}
51+
52+
function Example(props) {
53+
let container = props.useAlternateContainer ? document.getElementById('alternateContainer') : undefined;
54+
return (
55+
<OverlayProvider data-testid="root-provider">
56+
This is the root provider.
57+
{props.showModal &&
58+
<Modal container={container}>{props.children}</Modal>
59+
}
60+
</OverlayProvider>
61+
);
62+
}

packages/@react-aria/overlays/test/useModal.test.js

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function ModalDOM(props) {
2121

2222
function Modal(props) {
2323
return (
24-
<OverlayContainer data-testid={props.providerId || 'modal-provider'}>
24+
<OverlayContainer portalContainer={props.container} data-testid={props.providerId || 'modal-provider'}>
2525
<ModalDOM modalId={props.modalId}>{props.children}</ModalDOM>
2626
</OverlayContainer>
2727
);
@@ -32,7 +32,7 @@ function Example(props) {
3232
<OverlayProvider data-testid="root-provider">
3333
This is the root provider.
3434
{props.showModal &&
35-
<Modal>{props.children}</Modal>
35+
<Modal container={props.container}>{props.children}</Modal>
3636
}
3737
</OverlayProvider>
3838
);
@@ -88,4 +88,102 @@ describe('useModal', function () {
8888
res.rerender(<Example />);
8989
expect(rootProvider).not.toHaveAttribute('aria-hidden');
9090
});
91+
92+
it('can specify a different container from the default document.body', function () {
93+
let res = render(
94+
<div id="alternateContainer" data-testid="alternate-container">
95+
<Example container={document.getElementById('alternateContainer')} />
96+
</div>
97+
);
98+
let rootProvider = res.getByTestId('root-provider');
99+
100+
expect(rootProvider).not.toHaveAttribute('aria-hidden');
101+
102+
res.rerender(
103+
<div id="alternateContainer" data-testid="alternate-container">
104+
<Example showModal container={document.getElementById('alternateContainer')} />
105+
</div>
106+
);
107+
108+
let modalProvider = res.getByTestId('modal-provider');
109+
110+
expect(rootProvider).toHaveAttribute('aria-hidden', 'true');
111+
expect(modalProvider).not.toHaveAttribute('aria-hidden');
112+
113+
res.rerender(
114+
<div id="alternateContainer" data-testid="alternate-container">
115+
<Example showModal container={document.getElementById('alternateContainer')}>
116+
<Modal providerId="inner-modal-provider" modalId="inner-modal">Inner</Modal>
117+
</Example>
118+
</div>
119+
);
120+
121+
let innerModalProvider = res.getByTestId('inner-modal-provider');
122+
123+
expect(rootProvider).toHaveAttribute('aria-hidden', 'true');
124+
expect(modalProvider).toHaveAttribute('aria-hidden');
125+
expect(innerModalProvider).not.toHaveAttribute('aria-hidden');
126+
127+
res.rerender(
128+
<div id="alternateContainer" data-testid="alternate-container">
129+
<Example container={document.getElementById('alternateContainer')} />
130+
</div>
131+
);
132+
expect(rootProvider).not.toHaveAttribute('aria-hidden');
133+
});
134+
135+
describe('error state', function () {
136+
const consoleError = console.error;
137+
beforeEach(() => {
138+
console.error = jest.fn();
139+
});
140+
141+
afterEach(() => {
142+
console.error = consoleError;
143+
});
144+
it('if inside another container, throws an error', function () {
145+
let res = render(
146+
<div id="alternateContainer" data-testid="alternate-container">
147+
<Example container={document.getElementById('alternateContainer')}>
148+
<div id="nestedContainer" />
149+
</Example>
150+
</div>
151+
);
152+
let rootProvider = res.getByTestId('root-provider');
153+
154+
expect(rootProvider).not.toHaveAttribute('aria-hidden');
155+
156+
res.rerender(
157+
<div id="alternateContainer" data-testid="alternate-container">
158+
<Example showModal container={document.getElementById('alternateContainer')}>
159+
<div id="nestedContainer" />
160+
</Example>
161+
</div>
162+
);
163+
164+
let modalProvider = res.getByTestId('modal-provider');
165+
166+
expect(rootProvider).toHaveAttribute('aria-hidden', 'true');
167+
expect(modalProvider).not.toHaveAttribute('aria-hidden');
168+
expect(() =>
169+
res.rerender(
170+
<div id="alternateContainer" data-testid="alternate-container">
171+
<Example showModal container={document.getElementById('alternateContainer')}>
172+
<div id="nestedContainer" />
173+
<Modal
174+
container={document.getElementById('nestedContainer')}
175+
providerId="inner-modal-provider"
176+
modalId="inner-modal">
177+
Inner
178+
</Modal>
179+
</Example>
180+
</div>
181+
)
182+
).toThrow();
183+
expect(console.error).toHaveBeenCalledWith(
184+
expect.stringContaining('An OverlayContainer must not be inside another container. Please change the portalContainer prop.'),
185+
expect.anything()
186+
);
187+
});
188+
});
91189
});

0 commit comments

Comments
 (0)