diff --git a/docs/pages/api-docs/click-away-listener.md b/docs/pages/api-docs/click-away-listener.md
index fd793369a56171..9b81472f5c9a94 100644
--- a/docs/pages/api-docs/click-away-listener.md
+++ b/docs/pages/api-docs/click-away-listener.md
@@ -26,6 +26,7 @@ For instance, if you need to hide a menu when people click anywhere else on your
| Name | Type | Default | Description |
|:-----|:-----|:--------|:------------|
| children * | element | | The wrapped element. ⚠️ [Needs to be able to hold a ref](/guides/composition/#caveat-with-refs). |
+| disableReactTree | bool | false | If `true`, the React tree is ignored and only the DOM tree is considered. This prop changes how portaled elements are handled. |
| mouseEvent | 'onClick' | 'onMouseDown' | 'onMouseUp' | false | 'onClick' | The mouse event to listen to. You can disable the listener by providing `false`. |
| onClickAway * | func | | Callback fired when a "click away" event is detected. |
| touchEvent | 'onTouchStart' | 'onTouchEnd' | false | 'onTouchEnd' | The touch event to listen to. You can disable the listener by providing `false`. |
diff --git a/docs/src/pages/components/click-away-listener/ClickAway.js b/docs/src/pages/components/click-away-listener/ClickAway.js
index e45a91d5d5b64f..91d07c19e33cff 100644
--- a/docs/src/pages/components/click-away-listener/ClickAway.js
+++ b/docs/src/pages/components/click-away-listener/ClickAway.js
@@ -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,
@@ -31,12 +32,14 @@ export default function ClickAway() {
return (
-
+
{open ? (
-
Click me, I will stay visible until you click outside.
+
+ Click me, I will stay visible until you click outside.
+
Detect if a click event happened outside of an element. It listens for clicks that occur somewhere in the document.
- 📦 [1.5 kB gzipped](/size-snapshot).
+- ⚛️ Support portals
## Example
@@ -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"}}
diff --git a/docs/src/pages/components/portal/portal.md b/docs/src/pages/components/portal/portal.md
index f99a45ea8439ec..2c57fe249208df 100644
--- a/docs/src/pages/components/portal/portal.md
+++ b/docs/src/pages/components/portal/portal.md
@@ -5,7 +5,7 @@ components: Portal
# Portal
-
The portal component renders its children into a new "subtree" outside of current component hierarchy.
+
The portal component renders its children into a new "subtree" outside of current DOM hierarchy.
- 📦 [1.3 kB gzipped](/size-snapshot)
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.d.ts b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.d.ts
index d4d9179993f98b..8fab0de0a13b89 100644
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.d.ts
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.d.ts
@@ -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) => void;
touchEvent?: 'onTouchStart' | 'onTouchEnd' | false;
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
index b307f8d416d01e..a5b26792912c31 100644
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
@@ -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';
@@ -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;
@@ -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;
}
@@ -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
@@ -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);
@@ -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) {
@@ -117,14 +142,19 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
return undefined;
}, [handleClickAway, mouseEvent]);
- return {React.cloneElement(children, { ref: handleRef })};
-});
+ return {React.cloneElement(children, childrenProps)};
+}
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`.
*/
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
index 87063c1eb501e9..b154678a5a0446 100644
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
@@ -1,7 +1,10 @@
+/* eslint-disable jsx-a11y/click-events-have-key-events */
+/* eslint-disable jsx-a11y/no-static-element-interactions */
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { createClientRender, fireEvent } from 'test/utils/createClientRender';
+import Portal from '../Portal';
import ClickAwayListener from './ClickAwayListener';
describe('', () => {
@@ -56,6 +59,84 @@ describe('', () => {
document.body.removeEventListener('click', preventDefault);
});
+
+ it('should not be called when clicking inside a portaled element', () => {
+ const handleClickAway = spy();
+ const { getByText } = render(
+
+
+
+ Inside a portal
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('Inside a portal'));
+ expect(handleClickAway.callCount).to.equal(0);
+ });
+
+ it('should be called when clicking inside a portaled element and `disableReactTree` is `true`', () => {
+ const handleClickAway = spy();
+ const { getByText } = render(
+
+
+
+ Inside a portal
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('Inside a portal'));
+ expect(handleClickAway.callCount).to.equal(1);
+ });
+
+ it('should not be called even if the event propagation is stopped', () => {
+ const handleClickAway = spy();
+ const { getByText } = render(
+
+