Skip to content

Commit a2b2463

Browse files
authored
[ClickAwayListener] Fix support for portal (#20406)
1 parent e271861 commit a2b2463

File tree

10 files changed

+263
-41
lines changed

10 files changed

+263
-41
lines changed

docs/pages/api-docs/click-away-listener.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ For instance, if you need to hide a menu when people click anywhere else on your
2626
| Name | Type | Default | Description |
2727
|:-----|:-----|:--------|:------------|
2828
| <span class="prop-name required">children&nbsp;*</span> | <span class="prop-type">element</span> | | The wrapped element.<br>⚠️ [Needs to be able to hold a ref](/guides/composition/#caveat-with-refs). |
29+
| <span class="prop-name">disableReactTree</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the React tree is ignored and only the DOM tree is considered. This prop changes how portaled elements are handled. |
2930
| <span class="prop-name">mouseEvent</span> | <span class="prop-type">'onClick'<br>&#124;&nbsp;'onMouseDown'<br>&#124;&nbsp;'onMouseUp'<br>&#124;&nbsp;false</span> | <span class="prop-default">'onClick'</span> | The mouse event to listen to. You can disable the listener by providing `false`. |
3031
| <span class="prop-name required">onClickAway&nbsp;*</span> | <span class="prop-type">func</span> | | Callback fired when a "click away" event is detected. |
3132
| <span class="prop-name">touchEvent</span> | <span class="prop-type">'onTouchStart'<br>&#124;&nbsp;'onTouchEnd'<br>&#124;&nbsp;false</span> | <span class="prop-default">'onTouchEnd'</span> | The touch event to listen to. You can disable the listener by providing `false`. |

docs/src/pages/components/click-away-listener/ClickAway.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { makeStyles } from '@material-ui/core/styles';
33
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
44

55
const useStyles = makeStyles((theme) => ({
6-
wrapper: {
6+
root: {
77
position: 'relative',
88
},
9-
div: {
9+
dropdown: {
1010
position: 'absolute',
1111
top: 28,
1212
right: 0,
1313
left: 0,
14+
zIndex: 1,
1415
border: '1px solid',
1516
padding: theme.spacing(1),
1617
backgroundColor: theme.palette.background.paper,
@@ -31,12 +32,14 @@ export default function ClickAway() {
3132

3233
return (
3334
<ClickAwayListener onClickAway={handleClickAway}>
34-
<div className={classes.wrapper}>
35+
<div className={classes.root}>
3536
<button type="button" onClick={handleClick}>
3637
Open menu dropdown
3738
</button>
3839
{open ? (
39-
<div className={classes.div}>Click me, I will stay visible until you click outside.</div>
40+
<div className={classes.dropdown}>
41+
Click me, I will stay visible until you click outside.
42+
</div>
4043
) : null}
4144
</div>
4245
</ClickAwayListener>

docs/src/pages/components/click-away-listener/ClickAway.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import ClickAwayListener from '@material-ui/core/ClickAwayListener';
44

55
const useStyles = makeStyles((theme: Theme) =>
66
createStyles({
7-
wrapper: {
7+
root: {
88
position: 'relative',
99
},
10-
div: {
10+
dropdown: {
1111
position: 'absolute',
1212
top: 28,
1313
right: 0,
1414
left: 0,
15+
zIndex: 1,
1516
border: '1px solid',
1617
padding: theme.spacing(1),
1718
backgroundColor: theme.palette.background.paper,
@@ -33,12 +34,14 @@ export default function ClickAway() {
3334

3435
return (
3536
<ClickAwayListener onClickAway={handleClickAway}>
36-
<div className={classes.wrapper}>
37+
<div className={classes.root}>
3738
<button type="button" onClick={handleClick}>
3839
Open menu dropdown
3940
</button>
4041
{open ? (
41-
<div className={classes.div}>Click me, I will stay visible until you click outside.</div>
42+
<div className={classes.dropdown}>
43+
Click me, I will stay visible until you click outside.
44+
</div>
4245
) : null}
4346
</div>
4447
</ClickAwayListener>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React from 'react';
2+
import { makeStyles } from '@material-ui/core/styles';
3+
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
4+
import Portal from '@material-ui/core/Portal';
5+
6+
const useStyles = makeStyles((theme) => ({
7+
dropdown: {
8+
position: 'fixed',
9+
width: 200,
10+
top: '50%',
11+
left: '50%',
12+
transform: 'translate(-50%, -50%)',
13+
border: '1px solid',
14+
padding: theme.spacing(1),
15+
backgroundColor: theme.palette.background.paper,
16+
},
17+
}));
18+
19+
export default function PortalClickAway() {
20+
const classes = useStyles();
21+
const [open, setOpen] = React.useState(false);
22+
23+
const handleClick = () => {
24+
setOpen((prev) => !prev);
25+
};
26+
27+
const handleClickAway = () => {
28+
setOpen(false);
29+
};
30+
31+
return (
32+
<ClickAwayListener onClickAway={handleClickAway}>
33+
<div>
34+
<button type="button" onClick={handleClick}>
35+
Open menu dropdown
36+
</button>
37+
{open ? (
38+
<Portal>
39+
<div className={classes.dropdown}>
40+
Click me, I will stay visible until you click outside.
41+
</div>
42+
</Portal>
43+
) : null}
44+
</div>
45+
</ClickAwayListener>
46+
);
47+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from 'react';
2+
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
3+
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
4+
import Portal from '@material-ui/core/Portal';
5+
6+
const useStyles = makeStyles((theme: Theme) =>
7+
createStyles({
8+
dropdown: {
9+
position: 'fixed',
10+
width: 200,
11+
top: '50%',
12+
left: '50%',
13+
transform: 'translate(-50%, -50%)',
14+
border: '1px solid',
15+
padding: theme.spacing(1),
16+
backgroundColor: theme.palette.background.paper,
17+
},
18+
}),
19+
);
20+
21+
export default function PortalClickAway() {
22+
const classes = useStyles();
23+
const [open, setOpen] = React.useState(false);
24+
25+
const handleClick = () => {
26+
setOpen((prev) => !prev);
27+
};
28+
29+
const handleClickAway = () => {
30+
setOpen(false);
31+
};
32+
33+
return (
34+
<ClickAwayListener onClickAway={handleClickAway}>
35+
<div>
36+
<button type="button" onClick={handleClick}>
37+
Open menu dropdown
38+
</button>
39+
{open ? (
40+
<Portal>
41+
<div className={classes.dropdown}>
42+
Click me, I will stay visible until you click outside.
43+
</div>
44+
</Portal>
45+
) : null}
46+
</div>
47+
</ClickAwayListener>
48+
);
49+
}

docs/src/pages/components/click-away-listener/click-away-listener.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ components: ClickAwayListener
88
<p class="description">Detect if a click event happened outside of an element. It listens for clicks that occur somewhere in the document.</p>
99

1010
- 📦 [1.5 kB gzipped](/size-snapshot).
11+
- ⚛️ Support portals
1112

1213
## Example
1314

@@ -17,3 +18,9 @@ For instance, if you need to hide a menu dropdown when people click anywhere els
1718

1819
Notice that the component only accepts one child element.
1920
You can find a more advanced demo on the [Menu documentation section](/components/menus/#menulist-composition).
21+
22+
## Portal
23+
24+
The following demo uses [`Portal`](/components/portal/) to render the dropdown into a new "subtree" outside of current DOM hierarchy.
25+
26+
{{"demo": "pages/components/click-away-listener/PortalClickAway.js"}}

docs/src/pages/components/portal/portal.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ components: Portal
55

66
# Portal
77

8-
<p class="description">The portal component renders its children into a new "subtree" outside of current component hierarchy.</p>
8+
<p class="description">The portal component renders its children into a new "subtree" outside of current DOM hierarchy.</p>
99

1010
- 📦 [1.3 kB gzipped](/size-snapshot)
1111

packages/material-ui/src/ClickAwayListener/ClickAwayListener.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22

33
export interface ClickAwayListenerProps {
44
children: React.ReactNode;
5+
disableReactTree?: boolean;
56
mouseEvent?: 'onClick' | 'onMouseDown' | 'onMouseUp' | false;
67
onClickAway: (event: React.MouseEvent<Document>) => void;
78
touchEvent?: 'onTouchStart' | 'onTouchEnd' | false;

packages/material-ui/src/ClickAwayListener/ClickAwayListener.js

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as ReactDOM from 'react-dom';
33
import PropTypes from 'prop-types';
44
import ownerDocument from '../utils/ownerDocument';
55
import useForkRef from '../utils/useForkRef';
6-
import setRef from '../utils/setRef';
76
import useEventCallback from '../utils/useEventCallback';
87
import { elementAcceptingRef, exactProp } from '@material-ui/utils';
98

@@ -15,11 +14,18 @@ function mapEventPropToEvent(eventProp) {
1514
* Listen for click events that occur somewhere in the document, outside of the element itself.
1615
* For instance, if you need to hide a menu when people click anywhere else on your page.
1716
*/
18-
const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref) {
19-
const { children, mouseEvent = 'onClick', touchEvent = 'onTouchEnd', onClickAway } = props;
17+
function ClickAwayListener(props) {
18+
const {
19+
children,
20+
disableReactTree = false,
21+
mouseEvent = 'onClick',
22+
onClickAway,
23+
touchEvent = 'onTouchEnd',
24+
} = props;
2025
const movedRef = React.useRef(false);
2126
const nodeRef = React.useRef(null);
2227
const mountedRef = React.useRef(false);
28+
const syntheticEventRef = React.useRef(false);
2329

2430
React.useEffect(() => {
2531
mountedRef.current = true;
@@ -28,27 +34,28 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
2834
};
2935
}, []);
3036

31-
const handleNodeRef = useForkRef(nodeRef, ref);
3237
// can be removed once we drop support for non ref forwarding class components
33-
const handleOwnRef = React.useCallback(
34-
(instance) => {
35-
// #StrictMode ready
36-
setRef(handleNodeRef, ReactDOM.findDOMNode(instance));
37-
},
38-
[handleNodeRef],
39-
);
38+
const handleOwnRef = React.useCallback((instance) => {
39+
// #StrictMode ready
40+
nodeRef.current = ReactDOM.findDOMNode(instance);
41+
}, []);
4042
const handleRef = useForkRef(children.ref, handleOwnRef);
4143

44+
// The handler doesn't take event.defaultPrevented into account:
45+
//
46+
// event.preventDefault() is meant to stop default behaviours like
47+
// clicking a checkbox to check it, hitting a button to submit a form,
48+
// and hitting left arrow to move the cursor in a text input etc.
49+
// Only special HTML elements have these default behaviors.
4250
const handleClickAway = useEventCallback((event) => {
43-
// The handler doesn't take event.defaultPrevented into account:
44-
//
45-
// event.preventDefault() is meant to stop default behaviours like
46-
// clicking a checkbox to check it, hitting a button to submit a form,
47-
// and hitting left arrow to move the cursor in a text input etc.
48-
// Only special HTML elements have these default behaviors.
49-
50-
// IE 11 support, which trigger the handleClickAway even after the unbind
51-
if (!mountedRef.current) {
51+
// Given developers can stop the propagation of the synthetic event,
52+
// we can only be confident with a positive value.
53+
const insideReactTree = syntheticEventRef.current;
54+
syntheticEventRef.current = false;
55+
56+
// 1. IE 11 support, which trigger the handleClickAway even after the unbind
57+
// 2. The child might render null.
58+
if (!mountedRef.current || !nodeRef.current) {
5259
return;
5360
}
5461

@@ -58,11 +65,6 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
5865
return;
5966
}
6067

61-
// The child might render null.
62-
if (!nodeRef.current) {
63-
return;
64-
}
65-
6668
let insideDOM;
6769

6870
// If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js
@@ -71,25 +73,44 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
7173
} else {
7274
const doc = ownerDocument(nodeRef.current);
7375
// TODO v6 remove dead logic https://caniuse.com/#search=composedPath.
76+
// `doc.contains` works in modern browsers but isn't supported in IE 11:
77+
// https://github.com/timmywil/panzoom/issues/450
78+
// https://github.com/videojs/video.js/pull/5872
7479
insideDOM =
7580
!(doc.documentElement && doc.documentElement.contains(event.target)) ||
7681
nodeRef.current.contains(event.target);
7782
}
7883

79-
if (!insideDOM) {
84+
if (!insideDOM && (disableReactTree || !insideReactTree)) {
8085
onClickAway(event);
8186
}
8287
});
8388

84-
const handleTouchMove = React.useCallback(() => {
85-
movedRef.current = true;
86-
}, []);
89+
// Keep track of mouse/touch events that bubbled up through the portal.
90+
const createHandleSynthetic = (handlerName) => (event) => {
91+
syntheticEventRef.current = true;
92+
93+
const childrenPropsHandler = children.props[handlerName];
94+
if (childrenPropsHandler) {
95+
childrenPropsHandler(event);
96+
}
97+
};
98+
99+
const childrenProps = { ref: handleRef };
100+
101+
if (touchEvent !== false) {
102+
childrenProps[touchEvent] = createHandleSynthetic(touchEvent);
103+
}
87104

88105
React.useEffect(() => {
89106
if (touchEvent !== false) {
90107
const mappedTouchEvent = mapEventPropToEvent(touchEvent);
91108
const doc = ownerDocument(nodeRef.current);
92109

110+
const handleTouchMove = () => {
111+
movedRef.current = true;
112+
};
113+
93114
doc.addEventListener(mappedTouchEvent, handleClickAway);
94115
doc.addEventListener('touchmove', handleTouchMove);
95116

@@ -100,7 +121,11 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
100121
}
101122

102123
return undefined;
103-
}, [handleClickAway, handleTouchMove, touchEvent]);
124+
}, [handleClickAway, touchEvent]);
125+
126+
if (mouseEvent !== false) {
127+
childrenProps[mouseEvent] = createHandleSynthetic(mouseEvent);
128+
}
104129

105130
React.useEffect(() => {
106131
if (mouseEvent !== false) {
@@ -117,14 +142,19 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
117142
return undefined;
118143
}, [handleClickAway, mouseEvent]);
119144

120-
return <React.Fragment>{React.cloneElement(children, { ref: handleRef })}</React.Fragment>;
121-
});
145+
return <React.Fragment>{React.cloneElement(children, childrenProps)}</React.Fragment>;
146+
}
122147

123148
ClickAwayListener.propTypes = {
124149
/**
125150
* The wrapped element.
126151
*/
127152
children: elementAcceptingRef.isRequired,
153+
/**
154+
* If `true`, the React tree is ignored and only the DOM tree is considered.
155+
* This prop changes how portaled elements are handled.
156+
*/
157+
disableReactTree: PropTypes.bool,
128158
/**
129159
* The mouse event to listen to. You can disable the listener by providing `false`.
130160
*/

0 commit comments

Comments
 (0)