Skip to content

Commit dee9d69

Browse files
fix(portal): automatically nest portals if they are inside one another
1 parent 8785270 commit dee9d69

File tree

3 files changed

+189
-11
lines changed

3 files changed

+189
-11
lines changed

packages/clay-modal/src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const ClayModal: FunctionComponent<IProps> & {
4646
}: IProps) => {
4747
const [visibleClassShow, setVisibleClassShow] = useState<boolean>(false);
4848

49+
const modalBodyElementRef = useRef<HTMLDivElement | null>(null);
4950
const modalDialogElementRef = useRef<HTMLDivElement | null>(null);
5051

5152
/**
@@ -79,7 +80,7 @@ const ClayModal: FunctionComponent<IProps> & {
7980
}, []);
8081

8182
return (
82-
<ClayPortal>
83+
<ClayPortal subPortalRef={modalBodyElementRef}>
8384
<div
8485
className={classNames('modal-backdrop fade', {
8586
show: visibleClassShow,
@@ -90,6 +91,7 @@ const ClayModal: FunctionComponent<IProps> & {
9091
className={classNames('fade modal d-block', className, {
9192
show: visibleClassShow,
9293
})}
94+
ref={modalBodyElementRef}
9395
>
9496
<div
9597
className={classNames('modal-dialog', {

packages/clay-shared/src/Portal.tsx

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,44 @@
44
* SPDX-License-Identifier: BSD-3-Clause
55
*/
66

7-
import React, {useEffect, useRef} from 'react';
7+
import React, {createContext, useContext, useEffect, useRef} from 'react';
88
import {createPortal} from 'react-dom';
99

10+
const ClayPortalContext = createContext<React.RefObject<Element | null> | null>(
11+
null
12+
);
13+
14+
ClayPortalContext.displayName = 'ClayPortalContext';
15+
1016
export const ClayPortal: React.FunctionComponent<
11-
React.HTMLAttributes<HTMLDivElement>
12-
> = ({children}) => {
17+
React.HTMLAttributes<HTMLDivElement> & {
18+
/**
19+
* Ref of element to render portal into.
20+
*/
21+
containerRef?: React.RefObject<Element>;
22+
23+
/**
24+
* Ref of element to render nested portals into.
25+
*/
26+
subPortalRef?: React.RefObject<Element>;
27+
}
28+
> = ({children, containerRef, subPortalRef}) => {
29+
const parentPortalRef = useContext(ClayPortalContext);
1330
const portalRef = useRef(
14-
typeof document !== 'undefined' && document.createElement('div')
31+
typeof document !== 'undefined' ? document.createElement('div') : null
1532
);
16-
const elToMountTo = typeof document !== 'undefined' && document.body;
1733

1834
useEffect(() => {
35+
const closestParent =
36+
parentPortalRef && parentPortalRef.current
37+
? parentPortalRef.current
38+
: document.body;
39+
40+
const elToMountTo =
41+
containerRef && containerRef.current
42+
? containerRef.current
43+
: closestParent;
44+
1945
if (elToMountTo && portalRef.current) {
2046
elToMountTo.appendChild(portalRef.current);
2147
}
@@ -24,11 +50,17 @@ export const ClayPortal: React.FunctionComponent<
2450
elToMountTo.removeChild(portalRef.current);
2551
}
2652
};
27-
}, [elToMountTo]);
53+
}, [containerRef, parentPortalRef]);
2854

29-
if (portalRef.current) {
30-
return createPortal(children, portalRef.current);
31-
}
55+
const content = (
56+
<ClayPortalContext.Provider
57+
value={subPortalRef ? subPortalRef : portalRef}
58+
>
59+
{children}
60+
</ClayPortalContext.Provider>
61+
);
3262

33-
return <>{children}</>;
63+
return portalRef.current
64+
? createPortal(content, portalRef.current)
65+
: content;
3466
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*eslint no-console: 0 */
2+
/**
3+
* © 2019 Liferay, Inc. <https://liferay.com>
4+
*
5+
* SPDX-License-Identifier: BSD-3-Clause
6+
*/
7+
8+
import React from 'react';
9+
import {ClayPortal} from '..';
10+
import {cleanup, render} from '@testing-library/react';
11+
12+
describe('Portal', () => {
13+
afterEach(() => {
14+
jest.clearAllTimers();
15+
16+
cleanup();
17+
});
18+
19+
it('renders to document.body', () => {
20+
render(
21+
<div id="parent">
22+
<ClayPortal>
23+
<div id="portal" />
24+
</ClayPortal>
25+
</div>
26+
);
27+
28+
expect(
29+
document.body.contains(document.getElementById('portal'))
30+
).toBeTruthy();
31+
32+
expect(
33+
document
34+
.getElementById('parent')!
35+
.contains(document.getElementById('portal'))
36+
).not.toBeTruthy();
37+
});
38+
39+
it('renders multiple portals to document.body', () => {
40+
render(
41+
<div id="parent">
42+
<ClayPortal>
43+
<div id="portal1" />
44+
</ClayPortal>
45+
{'Normal Content'}
46+
<ClayPortal>
47+
<div id="portal2" />
48+
</ClayPortal>
49+
</div>
50+
);
51+
52+
expect(
53+
document.body.contains(document.getElementById('portal1'))
54+
).toBeTruthy();
55+
56+
expect(
57+
document.body.contains(document.getElementById('portal2'))
58+
).toBeTruthy();
59+
60+
expect(
61+
document
62+
.getElementById('parent')!
63+
.contains(document.getElementById('portal'))
64+
).not.toBeTruthy();
65+
});
66+
67+
it('renders inside existing portal', () => {
68+
render(
69+
<div id="parent">
70+
<ClayPortal>
71+
<div id="portal1">
72+
<ClayPortal>
73+
<div id="portal2" />
74+
</ClayPortal>
75+
</div>
76+
</ClayPortal>
77+
</div>
78+
);
79+
80+
const portalContainer = document.getElementById('portal1')!.parentNode!;
81+
82+
expect(
83+
document.getElementById('parent')!.contains(portalContainer)
84+
).toBeFalsy();
85+
86+
expect(
87+
portalContainer.contains(document.getElementById('portal2'))
88+
).toBeTruthy();
89+
});
90+
91+
it('renders inside defined container', () => {
92+
const App = () => {
93+
const contentRef = React.useRef<HTMLDivElement | null>(null);
94+
95+
return (
96+
<div>
97+
<div id="content" ref={contentRef}>
98+
{'content'}
99+
</div>
100+
101+
<ClayPortal containerRef={contentRef}>
102+
<div id="portal" />
103+
</ClayPortal>
104+
</div>
105+
);
106+
};
107+
108+
render(<App />);
109+
110+
expect(
111+
document
112+
.getElementById('content')!
113+
.contains(document.getElementById('portal'))
114+
).toBeTruthy();
115+
});
116+
117+
it('renders nested portals inside the defined container', () => {
118+
const App = () => {
119+
const contentRef = React.useRef<HTMLDivElement | null>(null);
120+
121+
return (
122+
<ClayPortal subPortalRef={contentRef}>
123+
<div>
124+
<div id="content" ref={contentRef}>
125+
{'content'}
126+
</div>
127+
</div>
128+
129+
<ClayPortal>
130+
<div id="portal" />
131+
</ClayPortal>
132+
</ClayPortal>
133+
);
134+
};
135+
136+
render(<App />);
137+
138+
expect(
139+
document
140+
.getElementById('content')!
141+
.contains(document.getElementById('portal'))
142+
).toBeTruthy();
143+
});
144+
});

0 commit comments

Comments
 (0)