Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ClickAwayListener] Fix support for portal #20406

Merged
merged 12 commits into from
Apr 6, 2020
1 change: 1 addition & 0 deletions docs/pages/api-docs/click-away-listener.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ For instance, if you need to hide a menu when people click anywhere else on your
| Name | Type | Default | Description |
|:-----|:-----|:--------|:------------|
| <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). |
| <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. |
| <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`. |
| <span class="prop-name required">onClickAway&nbsp;*</span> | <span class="prop-type">func</span> | | Callback fired when a "click away" event is detected. |
| <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`. |
Expand Down
11 changes: 7 additions & 4 deletions docs/src/pages/components/click-away-listener/ClickAway.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { makeStyles } from '@material-ui/core/styles';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';

const useStyles = makeStyles((theme) => ({
wrapper: {
root: {
position: 'relative',
},
div: {
dropdown: {
position: 'absolute',
top: 28,
right: 0,
left: 0,
zIndex: 1,
border: '1px solid',
padding: theme.spacing(1),
backgroundColor: theme.palette.background.paper,
Expand All @@ -31,12 +32,14 @@ export default function ClickAway() {

return (
<ClickAwayListener onClickAway={handleClickAway}>
<div className={classes.wrapper}>
<div className={classes.root}>
<button type="button" onClick={handleClick}>
Open menu dropdown
</button>
{open ? (
<div className={classes.div}>Click me, I will stay visible until you click outside.</div>
<div className={classes.dropdown}>
Click me, I will stay visible until you click outside.
</div>
) : null}
</div>
</ClickAwayListener>
Expand Down
11 changes: 7 additions & 4 deletions docs/src/pages/components/click-away-listener/ClickAway.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import ClickAwayListener from '@material-ui/core/ClickAwayListener';

const useStyles = makeStyles((theme: Theme) =>
createStyles({
wrapper: {
root: {
position: 'relative',
},
div: {
dropdown: {
position: 'absolute',
top: 28,
right: 0,
left: 0,
zIndex: 1,
border: '1px solid',
padding: theme.spacing(1),
backgroundColor: theme.palette.background.paper,
Expand All @@ -33,12 +34,14 @@ export default function ClickAway() {

return (
<ClickAwayListener onClickAway={handleClickAway}>
<div className={classes.wrapper}>
<div className={classes.root}>
<button type="button" onClick={handleClick}>
Open menu dropdown
</button>
{open ? (
<div className={classes.div}>Click me, I will stay visible until you click outside.</div>
<div className={classes.dropdown}>
Click me, I will stay visible until you click outside.
</div>
) : null}
</div>
</ClickAwayListener>
Expand Down
47 changes: 47 additions & 0 deletions docs/src/pages/components/click-away-listener/PortalClickAway.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import Portal from '@material-ui/core/Portal';

const useStyles = makeStyles((theme) => ({
dropdown: {
position: 'fixed',
width: 200,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
border: '1px solid',
padding: theme.spacing(1),
backgroundColor: theme.palette.background.paper,
},
}));

export default function PortalClickAway() {
const classes = useStyles();
const [open, setOpen] = React.useState(false);

const handleClick = () => {
setOpen((prev) => !prev);
};

const handleClickAway = () => {
setOpen(false);
};

return (
<ClickAwayListener onClickAway={handleClickAway}>
<div>
<button type="button" onClick={handleClick}>
Open menu dropdown
</button>
{open ? (
<Portal>
<div className={classes.dropdown}>
Click me, I will stay visible until you click outside.
</div>
</Portal>
) : null}
</div>
</ClickAwayListener>
);
}
49 changes: 49 additions & 0 deletions docs/src/pages/components/click-away-listener/PortalClickAway.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import Portal from '@material-ui/core/Portal';

const useStyles = makeStyles((theme: Theme) =>
createStyles({
dropdown: {
position: 'fixed',
width: 200,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
border: '1px solid',
padding: theme.spacing(1),
backgroundColor: theme.palette.background.paper,
},
}),
);

export default function PortalClickAway() {
const classes = useStyles();
const [open, setOpen] = React.useState(false);

const handleClick = () => {
setOpen((prev) => !prev);
};

const handleClickAway = () => {
setOpen(false);
};

return (
<ClickAwayListener onClickAway={handleClickAway}>
<div>
<button type="button" onClick={handleClick}>
Open menu dropdown
</button>
{open ? (
<Portal>
<div className={classes.dropdown}>
Click me, I will stay visible until you click outside.
</div>
</Portal>
) : null}
</div>
</ClickAwayListener>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ components: ClickAwayListener
<p class="description">Detect if a click event happened outside of an element. It listens for clicks that occur somewhere in the document.</p>

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

## Example

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

Notice that the component only accepts one child element.
You can find a more advanced demo on the [Menu documentation section](/components/menus/#menulist-composition).

## Portal

The following demo uses [`Portal`](/components/portal/) to render the dropdown into a new "subtree" outside of current DOM hierarchy.

{{"demo": "pages/components/click-away-listener/PortalClickAway.js"}}
2 changes: 1 addition & 1 deletion docs/src/pages/components/portal/portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ components: Portal

# Portal

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';

export interface ClickAwayListenerProps {
children: React.ReactNode;
disableReactTree?: boolean;
mouseEvent?: 'onClick' | 'onMouseDown' | 'onMouseUp' | false;
onClickAway: (event: React.MouseEvent<Document>) => void;
touchEvent?: 'onTouchStart' | 'onTouchEnd' | false;
Expand Down
94 changes: 62 additions & 32 deletions packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import * as ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import ownerDocument from '../utils/ownerDocument';
import useForkRef from '../utils/useForkRef';
import setRef from '../utils/setRef';
import useEventCallback from '../utils/useEventCallback';
import { elementAcceptingRef, exactProp } from '@material-ui/utils';

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

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

const handleNodeRef = useForkRef(nodeRef, ref);
// can be removed once we drop support for non ref forwarding class components
const handleOwnRef = React.useCallback(
(instance) => {
// #StrictMode ready
setRef(handleNodeRef, ReactDOM.findDOMNode(instance));
},
[handleNodeRef],
);
const handleOwnRef = React.useCallback((instance) => {
// #StrictMode ready
nodeRef.current = ReactDOM.findDOMNode(instance);
}, []);
const handleRef = useForkRef(children.ref, handleOwnRef);

// The handler doesn't take event.defaultPrevented into account:
//
// event.preventDefault() is meant to stop default behaviours like
// clicking a checkbox to check it, hitting a button to submit a form,
// and hitting left arrow to move the cursor in a text input etc.
// Only special HTML elements have these default behaviors.
const handleClickAway = useEventCallback((event) => {
// The handler doesn't take event.defaultPrevented into account:
//
// event.preventDefault() is meant to stop default behaviours like
// clicking a checkbox to check it, hitting a button to submit a form,
// and hitting left arrow to move the cursor in a text input etc.
// Only special HTML elements have these default behaviors.

// IE 11 support, which trigger the handleClickAway even after the unbind
if (!mountedRef.current) {
// Given developers can stop the propagation of the synthetic event,
// we can only be confident with a positive value.
const insideReactTree = syntheticEventRef.current;
syntheticEventRef.current = false;

// 1. IE 11 support, which trigger the handleClickAway even after the unbind
// 2. The child might render null.
if (!mountedRef.current || !nodeRef.current) {
return;
}

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

// The child might render null.
if (!nodeRef.current) {
return;
}

let insideDOM;

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

if (!insideDOM) {
if (!insideDOM && (disableReactTree || !insideReactTree)) {
onClickAway(event);
}
});

const handleTouchMove = React.useCallback(() => {
movedRef.current = true;
}, []);
// Keep track of mouse/touch events that bubbled up through the portal.
const createHandleSynthetic = (handlerName) => (event) => {
syntheticEventRef.current = true;

const childrenPropsHandler = children.props[handlerName];
if (childrenPropsHandler) {
childrenPropsHandler(event);
}
};

const childrenProps = { ref: handleRef };

if (touchEvent !== false) {
childrenProps[touchEvent] = createHandleSynthetic(touchEvent);
}

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

const handleTouchMove = () => {
movedRef.current = true;
};

doc.addEventListener(mappedTouchEvent, handleClickAway);
doc.addEventListener('touchmove', handleTouchMove);

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

return undefined;
}, [handleClickAway, handleTouchMove, touchEvent]);
}, [handleClickAway, touchEvent]);

if (mouseEvent !== false) {
childrenProps[mouseEvent] = createHandleSynthetic(mouseEvent);
}

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

return <React.Fragment>{React.cloneElement(children, { ref: handleRef })}</React.Fragment>;
});
return <React.Fragment>{React.cloneElement(children, childrenProps)}</React.Fragment>;
}

ClickAwayListener.propTypes = {
/**
* The wrapped element.
*/
children: elementAcceptingRef.isRequired,
/**
* If `true`, the React tree is ignored and only the DOM tree is considered.
* This prop changes how portaled elements are handled.
NMinhNguyen marked this conversation as resolved.
Show resolved Hide resolved
*/
disableReactTree: PropTypes.bool,
/**
* The mouse event to listen to. You can disable the listener by providing `false`.
*/
Expand Down
Loading