Skip to content

Commit

Permalink
[ClickAwayListener] Fix support for portal (#20406)
Browse files Browse the repository at this point in the history
  • Loading branch information
NMinhNguyen authored Apr 6, 2020
1 parent e271861 commit a2b2463
Show file tree
Hide file tree
Showing 10 changed files with 263 additions and 41 deletions.
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.
*/
disableReactTree: PropTypes.bool,
/**
* The mouse event to listen to. You can disable the listener by providing `false`.
*/
Expand Down
Loading

0 comments on commit a2b2463

Please sign in to comment.