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. +
) : null}
diff --git a/docs/src/pages/components/click-away-listener/ClickAway.tsx b/docs/src/pages/components/click-away-listener/ClickAway.tsx index 161672b39dd60d..3e7e19385ca49a 100644 --- a/docs/src/pages/components/click-away-listener/ClickAway.tsx +++ b/docs/src/pages/components/click-away-listener/ClickAway.tsx @@ -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, @@ -33,12 +34,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. +
) : null}
diff --git a/docs/src/pages/components/click-away-listener/PortalClickAway.js b/docs/src/pages/components/click-away-listener/PortalClickAway.js new file mode 100644 index 00000000000000..68f315a47bda06 --- /dev/null +++ b/docs/src/pages/components/click-away-listener/PortalClickAway.js @@ -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 ( + +
+ + {open ? ( + +
+ Click me, I will stay visible until you click outside. +
+
+ ) : null} +
+
+ ); +} diff --git a/docs/src/pages/components/click-away-listener/PortalClickAway.tsx b/docs/src/pages/components/click-away-listener/PortalClickAway.tsx new file mode 100644 index 00000000000000..3bd79bd4d81bf4 --- /dev/null +++ b/docs/src/pages/components/click-away-listener/PortalClickAway.tsx @@ -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 ( + +
+ + {open ? ( + +
+ Click me, I will stay visible until you click outside. +
+
+ ) : null} +
+
+ ); +} diff --git a/docs/src/pages/components/click-away-listener/click-away-listener.md b/docs/src/pages/components/click-away-listener/click-away-listener.md index 84dddb3231ae01..a73e46fe4a7842 100644 --- a/docs/src/pages/components/click-away-listener/click-away-listener.md +++ b/docs/src/pages/components/click-away-listener/click-away-listener.md @@ -8,6 +8,7 @@ components: ClickAwayListener

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( + +
+
{ + event.stopPropagation(); + }} + > + Outside a portal +
+ + { + event.stopPropagation(); + }} + > + Stop inside a portal + + + + { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + }} + > + Stop all inside a portal + + +
+
, + ); + + fireEvent.click(getByText('Outside a portal')); + expect(handleClickAway.callCount).to.equal(0); + + fireEvent.click(getByText('Stop all inside a portal')); + expect(handleClickAway.callCount).to.equal(0); + + fireEvent.click(getByText('Stop inside a portal')); + // True-negative, we don't have enough information to do otherwise. + expect(handleClickAway.callCount).to.equal(1); + }); }); describe('prop: mouseEvent', () => {