Repository: mui/material-ui (TypeScript) — Feature

Issue:
ClickAwayListener to ignore touchmove events
<!-- Checked checkbox should look like this: [x] -->
- [x] This is not a v0.x issue. <!-- (v0.x is no longer maintained) -->
- [x] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Expected Behavior 🤔
Much like the `Menu` I would expect the `ClickAwayListener` to ignore `touchstart`/`touchend` events where the user has scrolled, i.e. a `touchmove` event.

## Current Behavior 😯
Touch scrolling triggers `onClickAway`. My `Popper` closes when the user scrolls on a touch device.

## Examples 🌈
The `Menu` does not behave this way (see [https://github.com/mui-org/material-ui/issues/7547](url)).
I can understand some users may want this behaviour, but I think a property to ignore `touchmove` or similar would be nice.

## Context 🔦
I have a `Popper` I wish to remain open until the user explicitly clicks/touches outside of the `Popper`, allowing the user to scroll on a touch device.

My current workaround:
```jsx
class EventWrapper extends React.Component {
    state = {
        moving: false,
    }

    onTouchMove = () => {
        this.setState({ moving: true });
    };

    onClickAway = e => {
        const { closePopper } = this.props;
        const { moving } = this.state;
        if (moving) {
            e.cancel = true;
            this.setState({ moving: false });
            return;
        }
        closePopper();
    }

    render = () => {
        const { children } = this.props;
        return <React.Fragment>
            <EventListener target='document' onTouchMove={this.onTouchMove} />
            <ClickAwayListener
                onClickAway={this.onClickAway}
            >
                {children}
            </ClickAwayListener>
        </React.Fragment>;
    }
}
```
If this is flagged as helpful to Material UI I am happy to create a PR for this component. I would need to know how you want it handled (so as to retain current functionality).


Discussion:
@hsimah Thank you for opening this issue. I agree it's an important concern, we should be taking care of it. You need the state in your workaround, it can be a class instance variable. Otherwise, we can use a similar logic internally 👍. Do you want to work on it? :)

Resolving patch:
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
@@ -13,6 +13,8 @@ import ownerDocument from '../utils/ownerDocument';
 class ClickAwayListener extends React.Component {
   mounted = false;
 
+  moved = false;
+
   componentDidMount() {
     // Finds the first child when a component returns a fragment.
     // https://github.com/facebook/react/blob/036ae3c6e2f056adffc31dfb78d1b6f0c63272f0/packages/react-dom/src/__tests__/ReactDOMFiber-test.js#L105
@@ -35,6 +37,12 @@ class ClickAwayListener extends React.Component {
       return;
     }
 
+    // Do not act if user performed touchmove
+    if (this.moved) {
+      this.moved = false;
+      return;
+    }
+
     // The child might render null.
     if (!this.node) {
       return;
@@ -51,6 +59,10 @@ class ClickAwayListener extends React.Component {
     }
   };
 
+  handleTouchMove = () => {
+    this.moved = true;
+  };
+
   render() {
     const { children, mouseEvent, touchEvent, onClickAway, ...other } = this.props;
     const listenerProps = {};
@@ -59,6 +71,7 @@ class ClickAwayListener extends React.Component {
     }
     if (touchEvent !== false) {
       listenerProps[touchEvent] = this.handleClickAway;
+      listenerProps.onTouchMove = this.handleTouchMove;
     }
 
     return (


Test patch:
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
@@ -5,6 +5,16 @@ import { spy } from 'sinon';
 import { createMount } from '@material-ui/core/test-utils';
 import ClickAwayListener from './ClickAwayListener';
 
+function fireBodyMouseEvent(name, properties = {}) {
+  const event = document.createEvent('MouseEvents');
+  event.initEvent(name, true, true);
+  Object.keys(properties).forEach(key => {
+    event[key] = properties[key];
+  });
+  document.body.dispatchEvent(event);
+  return event;
+}
+
 describe('<ClickAwayListener />', () => {
   let mount;
   let wrapper;
@@ -36,9 +46,7 @@ describe('<ClickAwayListener />', () => {
         </ClickAwayListener>,
       );
 
-      const event = document.createEvent('MouseEvents');
-      event.initEvent('mouseup', true, true);
-      window.document.body.dispatchEvent(event);
+      const event = fireBodyMouseEvent('mouseup');
 
       assert.strictEqual(handleClickAway.callCount, 1);
       assert.deepEqual(handleClickAway.args[0], [event]);
@@ -85,11 +93,7 @@ describe('<ClickAwayListener />', () => {
           <span>Hello</span>
         </ClickAwayListener>,
       );
-
-      const event = document.createEvent('MouseEvents');
-      event.initEvent('mouseup', true, true);
-      window.document.body.dispatchEvent(event);
-
+      fireBodyMouseEvent('mouseup');
       assert.strictEqual(handleClickAway.callCount, 0);
     });
 
@@ -100,17 +104,9 @@ describe('<ClickAwayListener />', () => {
           <span>Hello</span>
         </ClickAwayListener>,
       );
-
-      const mouseUpEvent = document.createEvent('MouseEvents');
-      mouseUpEvent.initEvent('mouseup', true, true);
-      window.document.body.dispatchEvent(mouseUpEvent);
-
+      fireBodyMouseEvent('mouseup');
       assert.strictEqual(handleClickAway.callCount, 0);
-
-      const mouseDownEvent = document.createEvent('MouseEvents');
-      mouseDownEvent.initEvent('mousedown', true, true);
-      window.document.body.dispatchEvent(mouseDownEvent);
-
+      const mouseDownEvent = fireBodyMouseEvent('mousedown');
       assert.strictEqual(handleClickAway.callCount, 1);
       assert.deepEqual(handleClickAway.args[0], [mouseDownEvent]);
     });
@@ -124,11 +120,7 @@ describe('<ClickAwayListener />', () => {
           <span>Hello</span>
         </ClickAwayListener>,
       );
-
-      const event = document.createEvent('Events');
-      event.initEvent('touchend', true, true);
-      window.document.body.dispatchEvent(event);
-
+      fireBodyMouseEvent('touchend');
       assert.strictEqual(handleClickAway.callCount, 0);
     });
 
@@ -139,19 +131,28 @@ describe('<ClickAwayListener />', () => {
           <span>Hello</span>
         </ClickAwayListener>,
       );
-
-      const touchEndEvent = document.createEvent('Events');
-      touchEndEvent.initEvent('touchend', true, true);
-      window.document.body.dispatchEvent(touchEndEvent);
-
+      fireBodyMouseEvent('touchend');
       assert.strictEqual(handleClickAway.callCount, 0);
+      const touchStartEvent = fireBodyMouseEvent('touchstart');
+      assert.strictEqual(handleClickAway.callCount, 1);
+      assert.deepEqual(handleClickAway.args[0], [touchStartEvent]);
+    });
 
-      const touchStartEvent = document.createEvent('Events');
-      touchStartEvent.initEvent('touchstart', true, true);
-      window.document.body.dispatchEvent(touchStartEvent);
+    it('should ignore `touchend` when preceeded by `touchmove` event', () => {
+      const handleClickAway = spy();
+      wrapper = mount(
+        <ClickAwayListener onClickAway={handleClickAway} touchEvent="onTouchEnd">
+          <span>Hello</span>
+        </ClickAwayListener>,
+      );
+      fireBodyMouseEvent('touchstart');
+      fireBodyMouseEvent('touchmove');
+      fireBodyMouseEvent('touchend');
+      assert.strictEqual(handleClickAway.callCount, 0);
 
+      const touchEndEvent = fireBodyMouseEvent('touchend');
       assert.strictEqual(handleClickAway.callCount, 1);
-      assert.deepEqual(handleClickAway.args[0], [touchStartEvent]);
+      assert.deepEqual(handleClickAway.args[0], [touchEndEvent]);
     });
   });
 
@@ -164,11 +165,7 @@ describe('<ClickAwayListener />', () => {
         </ClickAwayListener>,
       );
       wrapper.instance().mounted = false;
-
-      const event = document.createEvent('MouseEvents');
-      event.initEvent('mouseup', true, true);
-      window.document.body.dispatchEvent(event);
-
+      fireBodyMouseEvent('mouseup');
       assert.strictEqual(handleClickAway.callCount, 0);
     });
   });
@@ -181,11 +178,7 @@ describe('<ClickAwayListener />', () => {
         <Child />
       </ClickAwayListener>,
     );
-
-    const event = document.createEvent('MouseEvents');
-    event.initEvent('mouseup', true, true);
-    window.document.body.dispatchEvent(event);
-
+    fireBodyMouseEvent('mouseup');
     assert.strictEqual(handleClickAway.callCount, 0);
   });
 });
diff --git a/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js b/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js
--- a/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js
+++ b/packages/material-ui/src/SwipeableDrawer/SwipeableDrawer.test.js
@@ -9,13 +9,14 @@ import SwipeableDrawer, { reset } from './SwipeableDrawer';
 import SwipeArea from './SwipeArea';
 import createMuiTheme from '../styles/createMuiTheme';
 
-function fireBodyMouseEvent(name, properties) {
+function fireBodyMouseEvent(name, properties = {}) {
   const event = document.createEvent('MouseEvents');
   event.initEvent(name, true, true);
   Object.keys(properties).forEach(key => {
     event[key] = properties[key];
   });
   document.body.dispatchEvent(event);
+  return event;
 }
 
 describe('<SwipeableDrawer />', () => {