Skip to content

Commit 05129ad

Browse files
breaking(Menu): Replacing hoistToBody with renderToPortal functionality
1 parent 7c25891 commit 05129ad

13 files changed

+203
-63
lines changed

.storybook/config.js

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { configure, addDecorator } from '@storybook/react';
33
import { withKnobs } from '@storybook/addon-knobs';
44
import '../src/rmwc/styles';
5+
import { Portal } from '@rmwc/base';
56

67
function requireAll(requireContext) {
78
return requireContext.keys().map(requireContext);
@@ -23,7 +24,15 @@ const StylesDecorator = storyFn => (
2324
</div>
2425
);
2526

27+
const PortalDecorator = storyFn => (
28+
<>
29+
{storyFn()}
30+
<Portal />
31+
</>
32+
);
33+
2634
addDecorator(withKnobs);
2735
addDecorator(StylesDecorator);
36+
addDecorator(PortalDecorator);
2837

2938
configure(loadStories, module);

src/base/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export * from './with-theme';
55
export * from './utils';
66
export * from './foundation-component';
77
export * from './component';
8+
export * from './portal';
89

910
export type WithThemeProps = _WithThemeProps;

src/base/portal.tsx

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React, { useRef, useEffect, useState } from 'react';
2+
import ReactDOM from 'react-dom';
3+
4+
const PORTAL_ID = 'rmwcPortal';
5+
6+
export type PortalPropT = Element | string | boolean | undefined | null;
7+
8+
export function Portal() {
9+
const el = useRef(document.createElement('div'));
10+
11+
return <div ref={el} id={PORTAL_ID} />;
12+
}
13+
14+
export function PortalChild({
15+
children,
16+
renderTo
17+
}: {
18+
children: React.ReactNode;
19+
renderTo?: PortalPropT;
20+
}) {
21+
const [portalEl, setPortalEl] = useState<Element | undefined>();
22+
23+
useEffect(() => {
24+
let element: Element | undefined = undefined;
25+
26+
if (renderTo === true) {
27+
element = document.getElementById(PORTAL_ID) || undefined;
28+
} else if (typeof renderTo === 'string') {
29+
element = document.querySelector(renderTo) || undefined;
30+
} else if (renderTo instanceof Element) {
31+
element = renderTo;
32+
}
33+
34+
if (element !== portalEl) {
35+
setPortalEl(element);
36+
}
37+
}, [renderTo, portalEl]);
38+
39+
if (portalEl) {
40+
return ReactDOM.createPortal(children, portalEl);
41+
}
42+
43+
return <>{children}</>;
44+
}

src/base/utils/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './get-display-name';
1313
export * from './empty-client-rect';
1414
export * from './data-table-context';
1515
export * from './trigger-window-resize';
16+
export * from './raf';

src/base/utils/raf.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* A helper for when we have multiple requestion animation frames
3+
* Usage:
4+
* raf(() => doSomething, 3);
5+
*/
6+
export const raf = (
7+
callback: () => void,
8+
frames: number = 1,
9+
_iteration = 1
10+
) => {
11+
window.requestAnimationFrame(() => {
12+
_iteration >= frames ? callback() : raf(callback, frames, _iteration + 1);
13+
});
14+
};

src/menu/generated-examples.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
["function Example() {\n const [open, setOpen] = React.useState(false);\n\n return (\n <MenuSurfaceAnchor>\n <Menu\n open={open}\n onSelect={evt => console.log(evt.detail.index)}\n onClose={evt => setOpen(false)}\n >\n <MenuItem>Cookies</MenuItem>\n <MenuItem>Pizza</MenuItem>\n {/** MenuItem is just a ListItem, so you can intermingle other List components */}\n <ListDivider />\n <MenuItem>Icecream</MenuItem>\n </Menu>\n\n <Button raised onClick={evt => setOpen(!open)}>\n Menu\n </Button>\n </MenuSurfaceAnchor>\n );\n}","function Example() {\n const [open, setOpen] = React.useState(false);\n\n return (\n <MenuSurfaceAnchor>\n <MenuSurface open={open} onClose={evt => setOpen(false)}>\n <div style={{ padding: '1rem', width: '8rem' }}>\n Make the content whatever you want.\n </div>\n </MenuSurface>\n\n <Button raised onClick={evt => setOpen(!open)}>\n Menu Surface\n </Button>\n </MenuSurfaceAnchor>\n );\n}","function Example() {\n const [open, setOpen] = React.useState(false);\n\n return (\n <MenuSurfaceAnchor>\n <MenuSurface open={open} onClose={evt => setOpen(false)}>\n <div style={{ padding: '1rem', width: '8rem' }}>Menu</div>\n </MenuSurface>\n {/** The handle can be any component you want */}\n <IconButton icon=\"menu\" onClick={evt => setOpen(!open)} />\n </MenuSurfaceAnchor>\n );\n}","<SimpleMenu handle={<Button>Simple Menu</Button>}>\n <MenuItem>Cookies</MenuItem>\n <MenuItem>Pizza</MenuItem>\n <MenuItem>Icecream</MenuItem>\n</SimpleMenu>","<SimpleMenuSurface handle={<Button>Simple Menu Surface</Button>}>\n <div style={{ padding: '1rem', width: '8rem' }}>\n Make the content whatever you want.\n </div>\n</SimpleMenuSurface>","function Example() {\n const [anchorCorner, setAnchorCorner] = React.useState(\n 'topLeft'\n );\n\n return (\n <>\n <MenuSurfaceAnchor>\n <MenuSurface anchorCorner={anchorCorner} open={true}>\n <div style={{ padding: '1rem', width: '8rem' }}>\n anchorCorner: {anchorCorner}\n </div>\n </MenuSurface>\n <Button raised label=\"Anchored Menu\" />\n </MenuSurfaceAnchor>\n\n <Select\n value={anchorCorner}\n label=\"anchorCorner\"\n onChange={evt => setAnchorCorner(evt.currentTarget.value)}\n options={[\n 'topLeft',\n 'topRight',\n 'bottomLeft',\n 'bottomRight',\n 'topStart',\n 'topEnd',\n 'bottomStart',\n 'bottomEnd'\n ]}\n />\n </>\n );\n}"]
1+
["function Example() {\n const [open, setOpen] = React.useState(false);\n\n return (\n <MenuSurfaceAnchor>\n <Menu\n open={open}\n onSelect={evt => console.log(evt.detail.index)}\n onClose={evt => setOpen(false)}\n >\n <MenuItem>Cookies</MenuItem>\n <MenuItem>Pizza</MenuItem>\n {/** MenuItem is just a ListItem, so you can intermingle other List components */}\n <ListDivider />\n <MenuItem>Icecream</MenuItem>\n </Menu>\n\n <Button raised onClick={evt => setOpen(!open)}>\n Menu\n </Button>\n </MenuSurfaceAnchor>\n );\n}","function Example() {\n const [open, setOpen] = React.useState(false);\n\n return (\n <MenuSurfaceAnchor>\n <MenuSurface open={open} onClose={evt => setOpen(false)}>\n <div style={{ padding: '1rem', width: '8rem' }}>\n Make the content whatever you want.\n </div>\n </MenuSurface>\n\n <Button raised onClick={evt => setOpen(!open)}>\n Menu Surface\n </Button>\n </MenuSurfaceAnchor>\n );\n}","function Example() {\n const [open, setOpen] = React.useState(false);\n\n return (\n <MenuSurfaceAnchor>\n <MenuSurface open={open} onClose={evt => setOpen(false)}>\n <div style={{ padding: '1rem', width: '8rem' }}>Menu</div>\n </MenuSurface>\n {/** The handle can be any component you want */}\n <IconButton icon=\"menu\" onClick={evt => setOpen(!open)} />\n </MenuSurfaceAnchor>\n );\n}","<SimpleMenu handle={<Button>Simple Menu</Button>}>\n <MenuItem>Cookies</MenuItem>\n <MenuItem>Pizza</MenuItem>\n <MenuItem>Icecream</MenuItem>\n</SimpleMenu>","<SimpleMenuSurface handle={<Button>Simple Menu Surface</Button>}>\n <div style={{ padding: '1rem', width: '8rem' }}>\n Make the content whatever you want.\n </div>\n</SimpleMenuSurface>","function Example() {\n const [anchorCorner, setAnchorCorner] = React.useState(\n 'topLeft'\n );\n\n return (\n <>\n <MenuSurfaceAnchor>\n <MenuSurface anchorCorner={anchorCorner} open={true}>\n <div style={{ padding: '1rem', width: '8rem' }}>\n anchorCorner: {anchorCorner}\n </div>\n </MenuSurface>\n <Button raised label=\"Anchored Menu\" />\n </MenuSurfaceAnchor>\n\n <Select\n value={anchorCorner}\n label=\"anchorCorner\"\n onChange={evt => setAnchorCorner(evt.currentTarget.value)}\n options={[\n 'topLeft',\n 'topRight',\n 'bottomLeft',\n 'bottomRight',\n 'topStart',\n 'topEnd',\n 'bottomStart',\n 'bottomEnd'\n ]}\n />\n </>\n );\n}","\n // Somewhere at the top level of your app\n // Render the RMWC Portal\n // You only have to do this once\n import React from 'react';\n import { Portal } from '@rmwc/base';\n\n export default function App() {\n return (\n <div>\n ...\n <Portal />\n </div>\n )\n }\n","function Example() {\n const [renderToPortal, setRenderToPortal] = React.useState(true);\n const options = ['Cookies', 'Pizza', 'Icecream'];\n return (\n <>\n <div\n style={{\n marginBottom: '10rem',\n height: '3.5rem',\n overflow: 'hidden'\n }}\n >\n <MenuSurfaceAnchor>\n <Button raised>Open Menu</Button>\n <Menu open renderToPortal={renderToPortal}>\n {options.map(o => (\n <MenuItem key={o}>{o}</MenuItem>\n ))}\n </Menu>\n </MenuSurfaceAnchor>\n </div>\n <Checkbox\n checked={renderToPortal}\n onChange={evt => setRenderToPortal(evt.currentTarget.checked)}\n label=\"renderToPortal\"\n />\n </>\n );\n}"]

src/menu/menu-surface-foundation.tsx

+37-50
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import {
22
useFoundation,
33
closest,
44
emptyClientRect,
5-
FoundationElement
5+
FoundationElement,
6+
raf
67
} from '@rmwc/base';
78
import { MenuSurfaceProps, MenuSurfaceApi } from './menu-surface';
89

@@ -39,7 +40,6 @@ export const useMenuSurfaceFoundation = (
3940
const firstFocusableElementRef = useRef<HTMLElement | null>(null);
4041
const previousFocusRef = useRef<HTMLElement | null>(null);
4142
const anchorElementRef = useRef<HTMLElement | null>(null);
42-
const hoistedRef = useRef(false);
4343

4444
const { foundation, ...elements } = useFoundation({
4545
props,
@@ -52,7 +52,9 @@ export const useMenuSurfaceFoundation = (
5252
rootEl: FoundationElement<any, any>;
5353
}): MenuSurfaceApi => {
5454
return {
55-
hoistMenuToBody: () => hoistMenuToBody(),
55+
hoistMenuToBody: () => {
56+
// this is controlled by the renderToPortal prop
57+
},
5658
setAnchorCorner: (corner: Corner) => foundation.setAnchorCorner(corner),
5759
setAnchorElement: (element: HTMLElement) =>
5860
(anchorElementRef.current = element),
@@ -233,32 +235,6 @@ export const useMenuSurfaceFoundation = (
233235

234236
rootEl.setProp('onKeyDown', handleKeydown, true);
235237

236-
const hoistMenuToBody = useCallback(() => {
237-
if (rootEl.ref?.parentElement) {
238-
document.body.appendChild(
239-
rootEl.ref.parentElement.removeChild(rootEl.ref)
240-
);
241-
hoistedRef.current = true;
242-
foundation.setIsHoisted(true);
243-
244-
// correct layout for open menu
245-
if (foundation.isOpen()) {
246-
// wait an extra frame so that the element is actually
247-
// done being hoisted and painting. Fixes Issue #453
248-
// @ts-ignore unsafe private variable access
249-
window.requestAnimationFrame(() => foundation.autoPosition_());
250-
}
251-
}
252-
}, [foundation, rootEl.ref]);
253-
254-
const unhoistMenuFromBody = useCallback(() => {
255-
if (anchorElementRef.current && rootEl.ref) {
256-
anchorElementRef.current.appendChild(rootEl.ref);
257-
hoistedRef.current = false;
258-
foundation.setIsHoisted(false);
259-
}
260-
}, [foundation, rootEl.ref]);
261-
262238
// fixed
263239
useEffect(() => {
264240
foundation.setFixedPosition(!!props.fixed);
@@ -277,12 +253,34 @@ export const useMenuSurfaceFoundation = (
277253
}
278254
}, [rootEl.ref]);
279255

280-
// hoistToBody
256+
// renderToPortal
281257
useEffect(() => {
282-
if (props.hoistToBody !== undefined) {
283-
props.hoistToBody ? hoistMenuToBody() : unhoistMenuFromBody();
284-
}
285-
}, [props.hoistToBody, foundation, hoistMenuToBody, unhoistMenuFromBody]);
258+
props.renderToPortal
259+
? foundation.setIsHoisted(true)
260+
: foundation.setIsHoisted(false);
261+
262+
const autoPosition = () => {
263+
try {
264+
// silence this, it blows up loudly occasionally
265+
// @ts-ignore unsafe private variable access
266+
foundation.autoPosition_();
267+
} catch (err) {}
268+
};
269+
270+
// wait an extra frame so that the element is actually
271+
// done being hoisted and painting. Fixes Issue #453
272+
const handler = props.renderToPortal ? autoPosition : () => {};
273+
274+
raf(() => {
275+
foundation.isOpen() && autoPosition();
276+
});
277+
278+
// fix positioning on window resize when renderToPortal is true
279+
window.addEventListener('resize', handler);
280+
return () => {
281+
window.removeEventListener('resize', handler);
282+
};
283+
}, [props.renderToPortal, foundation]);
286284

287285
// anchorCorner
288286
useEffect(() => {
@@ -315,28 +313,17 @@ export const useMenuSurfaceFoundation = (
315313
// changing the open prop externally, while also not
316314
// conflicting with any internal events that might have closed
317315
// the menu, like a body click or escape key
318-
window.requestAnimationFrame(() => {
319-
window.requestAnimationFrame(() => {
320-
if (foundation.isOpen()) {
321-
foundation.close();
322-
}
323-
});
324-
});
316+
raf(() => {
317+
if (foundation.isOpen()) {
318+
foundation.close();
319+
}
320+
}, 2);
325321
}
326322
}, [open, foundation, rootEl.ref]);
327323

328324
useEffect(() => {
329325
setOpen(!!props.open);
330326
}, [props.open]);
331327

332-
useEffect(() => {
333-
return () => {
334-
// @ts-ignore Fixes unsafe access from MDC when component unmounts
335-
foundation.adapter_.notifyClose = () => {};
336-
unhoistMenuFromBody();
337-
};
338-
// eslint-disable-next-line react-hooks/exhaustive-deps
339-
}, []);
340-
341328
return { ...elements };
342329
};

src/menu/menu-surface.tsx

+9-6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react';
33
import { Corner, MDCMenuSurfaceFoundation } from '@material/menu-surface';
44
import { useClassNames, Tag, createComponent } from '@rmwc/base';
55
import { useMenuSurfaceFoundation } from './menu-surface-foundation';
6+
import { PortalChild, PortalPropT } from '@rmwc/base';
67

78
export type AnchorT =
89
| 'bottomEnd'
@@ -30,8 +31,8 @@ export interface MenuSurfaceProps {
3031
open?: boolean;
3132
/** Make the menu position fixed. */
3233
fixed?: boolean;
33-
/** Moves the menu to the body. Useful for situations where the content might be cutoff by an overflow: hidden container. */
34-
hoistToBody?: boolean;
34+
/** Renders the menu to a portal. Useful for situations where the content might be cutoff by an overflow: hidden container. You can pass "true" to render to the default RMWC portal. */
35+
renderToPortal?: PortalPropT;
3536
/** Manually position the menu to one of the corners. */
3637
anchorCorner?: AnchorT;
3738
/** Callback for when the menu is opened. */
@@ -59,7 +60,7 @@ export const MenuSurface = createComponent<MenuSurfaceProps>(
5960
anchorCorner,
6061
onOpen,
6162
onClose,
62-
hoistToBody,
63+
renderToPortal,
6364
fixed,
6465
apiRef,
6566
foundationRef,
@@ -76,9 +77,11 @@ export const MenuSurface = createComponent<MenuSurfaceProps>(
7677
]);
7778

7879
return (
79-
<Tag {...rest} element={rootEl} className={className} ref={ref}>
80-
{children}
81-
</Tag>
80+
<PortalChild renderTo={renderToPortal}>
81+
<Tag {...rest} element={rootEl} className={className} ref={ref}>
82+
{children}
83+
</Tag>
84+
</PortalChild>
8285
);
8386
}
8487
);

src/menu/menu.story.tsx

+11-2
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ function MenuHoist() {
122122
>
123123
Open Menu
124124
</Button>
125-
<Menu open={open} hoistToBody={hoisted} onClose={() => setOpen(false)}>
125+
<Menu
126+
open={open}
127+
renderToPortal={hoisted}
128+
onClose={() => setOpen(false)}
129+
>
126130
{options.map((o: string) => (
127131
<MenuItem key={o}>{o}</MenuItem>
128132
))}
@@ -145,7 +149,12 @@ storiesOf('Menus', module)
145149
</Menu>
146150
);
147151
})
148-
.add('Menu: hoistToBody', () => <MenuHoist />)
152+
.add('Menu: hoistToBody', () => (
153+
<>
154+
<MenuHoist />
155+
<MenuHoist />
156+
</>
157+
))
149158
.add('SimpleMenu', () => (
150159
<SimpleMenu handle={<Button raised>Open Simple Menu</Button>}>
151160
<MenuItem>Cookies</MenuItem>

src/menu/readme.tsx

+71-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ListDivider } from '../list';
1717
import { Button } from '../button';
1818
import { Select } from '../select';
1919
import { IconButton } from '../icon-button';
20+
import { Checkbox } from '../checkbox';
2021

2122
export default function() {
2223
return (
@@ -128,7 +129,7 @@ export default function() {
128129
<DocsSubtitle>Anchoring</DocsSubtitle>
129130
<DocsP>
130131
By default, Menus will attempt to automatically position themselves, but
131-
this behavior can be overriden by setting the `anchorCorner` prop.
132+
this behavior can be overridden by setting the `anchorCorner` prop.
132133
</DocsP>
133134

134135
<DocsExample>
@@ -168,6 +169,75 @@ export default function() {
168169
}}
169170
</DocsExample>
170171

172+
<DocsSubtitle>Rendering through Portals</DocsSubtitle>
173+
<DocsP>
174+
Occasionally, you may find your menu being cut off from being inside a
175+
container that is styled to be `overflow:hidden`. RMWC provides a
176+
`renderToPortal` prop that lets you use React's portal functionality to
177+
render the menu dropdown in a different container.
178+
</DocsP>
179+
180+
<DocsP>
181+
You can specify any element or selector you want, but the simplest
182+
method is to pass `true` and use RMWC's built in `Portal` component.
183+
</DocsP>
184+
185+
<DocsExample codeOnly>
186+
{/* jsx */ `
187+
// Somewhere at the top level of your app
188+
// Render the RMWC Portal
189+
// You only have to do this once
190+
import React from 'react';
191+
import { Portal } from '@rmwc/base';
192+
193+
export default function App() {
194+
return (
195+
<div>
196+
...
197+
<Portal />
198+
</div>
199+
)
200+
}
201+
`}
202+
</DocsExample>
203+
204+
<DocsP>
205+
Now you can use the `renderToPortal` prop. Below is a contrived example
206+
of a menu being cut off due to `overflow: hidden`.
207+
</DocsP>
208+
209+
<DocsExample>
210+
{function Example() {
211+
const [renderToPortal, setRenderToPortal] = React.useState(true);
212+
const options = ['Cookies', 'Pizza', 'Icecream'];
213+
return (
214+
<>
215+
<div
216+
style={{
217+
marginBottom: '10rem',
218+
height: '3.5rem',
219+
overflow: 'hidden'
220+
}}
221+
>
222+
<MenuSurfaceAnchor>
223+
<Button raised>Open Menu</Button>
224+
<Menu open renderToPortal={renderToPortal}>
225+
{options.map(o => (
226+
<MenuItem key={o}>{o}</MenuItem>
227+
))}
228+
</Menu>
229+
</MenuSurfaceAnchor>
230+
</div>
231+
<Checkbox
232+
checked={renderToPortal}
233+
onChange={evt => setRenderToPortal(evt.currentTarget.checked)}
234+
label="renderToPortal"
235+
/>
236+
</>
237+
);
238+
}}
239+
</DocsExample>
240+
171241
<DocProps
172242
src={propsSrc}
173243
components={[

0 commit comments

Comments
 (0)