Repository: mui/material-ui (TypeScript)
Date: 2019-12-10 02:07:25+00:00
Task type: Feature

Issue:
[ClickAwayListener] Accept default prevented events
By default, ClickAwayListener ignores events that use event.preventDefault(). While this is desired in most cases, there are some cases where this should be a configurable feature.

- [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.

## Summary 💡
When using event.preventDefault() or equivalent functions, ClickAwayListener ignores the event by checking event.defaultPrevented. In most cases this is fine. A majority of my buttons are anchors with a href to the action they carryout. I do this so a user can decide to right-click and open the action in a new window if they don't want it happening in the main app window. To do this, event.preventDefault() is needed. event.preventDefault() is also used in react-router-dom Link for navigation.

I'm suggesting that we add a prop that is similar to "allowPreventDefaultEvents" to bypass this check so these events will trigger onClickAway.

## Examples 🌈
Not many examples I can think of except what is already stated since this isn't a visual component.



Issue discussion:
What about removing this logic? Per
https://github.com/mui-org/material-ui/blob/d30e5ae648d7b499161f4dd31da8c846d99a990e/packages/material-ui/src/Modal/Modal.js#L186-L191
I think that we could add a `allowPreventDefaultEvents` prop until we change the behavior in v5, at which point, we can remove it. Would that work for you? Do you want to submit a pull request? :)
I think removing it would be great, I just don't know the ramifications for other use cases. I will look into doing a pull request, it has been a long while since I have done one.

Solution 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
@@ -40,10 +40,12 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
   const handleRef = useForkRef(children.ref, handleOwnRef);
 
   const handleClickAway = useEventCallback(event => {
-    // Ignore events that have been `event.preventDefault()` marked.
-    if (event.defaultPrevented) {
-      return;
-    }
+    // 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) {
diff --git a/packages/material-ui/src/Modal/Modal.js b/packages/material-ui/src/Modal/Modal.js
--- a/packages/material-ui/src/Modal/Modal.js
+++ b/packages/material-ui/src/Modal/Modal.js
@@ -183,7 +183,7 @@ const Modal = React.forwardRef(function Modal(inProps, ref) {
   };
 
   const handleKeyDown = event => {
-    // We don't take event.defaultPrevented into account:
+    // 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,


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
@@ -1,87 +1,58 @@
 import React from 'react';
-import { assert } from 'chai';
+import { expect } from 'chai';
 import { spy } from 'sinon';
-import { createMount } from '@material-ui/core/test-utils';
+import { createClientRender, fireEvent } from 'test/utils/createClientRender';
 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;
-
-  before(() => {
-    mount = createMount({ strict: true });
-  });
-
-  afterEach(() => {
-    wrapper.unmount();
-  });
-
-  after(() => {
-    mount.cleanUp();
-  });
+  const render = createClientRender();
 
   it('should render the children', () => {
-    const children = <span>Hello</span>;
-    wrapper = mount(<ClickAwayListener onClickAway={() => {}}>{children}</ClickAwayListener>);
-    assert.strictEqual(wrapper.contains(children), true);
+    const children = <span />;
+    const { container } = render(
+      <ClickAwayListener onClickAway={() => {}}>{children}</ClickAwayListener>,
+    );
+    expect(container.querySelectorAll('span').length).to.equal(1);
   });
 
   describe('prop: onClickAway', () => {
-    it('should be call when clicking away', () => {
+    it('should be called when clicking away', () => {
       const handleClickAway = spy();
-      wrapper = mount(
+      render(
         <ClickAwayListener onClickAway={handleClickAway}>
-          <span>Hello</span>
+          <span />
         </ClickAwayListener>,
       );
 
-      const event = fireBodyMouseEvent('click');
-
-      assert.strictEqual(handleClickAway.callCount, 1);
-      assert.deepEqual(handleClickAway.args[0], [event]);
+      fireEvent.click(document.body);
+      expect(handleClickAway.callCount).to.equal(1);
+      expect(handleClickAway.args[0].length).to.equal(1);
     });
 
-    it('should not be call when clicking inside', () => {
+    it('should not be called when clicking inside', () => {
       const handleClickAway = spy();
-      const ref = React.createRef();
-      wrapper = mount(
+      const { container } = render(
         <ClickAwayListener onClickAway={handleClickAway}>
-          <span>Hello</span>
+          <span />
         </ClickAwayListener>,
       );
 
-      const event = new window.Event('click', { view: window, bubbles: true, cancelable: true });
-      const el = ref.current;
-      if (el) {
-        el.dispatchEvent(event);
-      }
-
-      assert.strictEqual(handleClickAway.callCount, 0);
+      fireEvent.click(container.querySelector('span'));
+      expect(handleClickAway.callCount).to.equal(0);
     });
 
-    it('should not be call when defaultPrevented', () => {
+    it('should be called when preventDefault is `true`', () => {
       const handleClickAway = spy();
-      wrapper = mount(
+      render(
         <ClickAwayListener onClickAway={handleClickAway}>
-          <span>Hello</span>
+          <span />
         </ClickAwayListener>,
       );
       const preventDefault = event => event.preventDefault();
       document.body.addEventListener('click', preventDefault);
 
-      const event = new window.Event('click', { view: window, bubbles: true, cancelable: true });
-      document.body.dispatchEvent(event);
-      assert.strictEqual(handleClickAway.callCount, 0);
+      fireEvent.click(document.body);
+      expect(handleClickAway.callCount).to.equal(1);
 
       document.body.removeEventListener('click', preventDefault);
     });
@@ -90,83 +61,83 @@ describe('<ClickAwayListener />', () => {
   describe('prop: mouseEvent', () => {
     it('should not call `props.onClickAway` when `props.mouseEvent` is `false`', () => {
       const handleClickAway = spy();
-      wrapper = mount(
+      render(
         <ClickAwayListener onClickAway={handleClickAway} mouseEvent={false}>
-          <span>Hello</span>
+          <span />
         </ClickAwayListener>,
       );
-      fireBodyMouseEvent('click');
-      assert.strictEqual(handleClickAway.callCount, 0);
+      fireEvent.click(document.body);
+      expect(handleClickAway.callCount).to.equal(0);
     });
 
     it('should call `props.onClickAway` when the appropriate mouse event is triggered', () => {
       const handleClickAway = spy();
-      wrapper = mount(
+      render(
         <ClickAwayListener onClickAway={handleClickAway} mouseEvent="onMouseDown">
-          <span>Hello</span>
+          <span />
         </ClickAwayListener>,
       );
-      fireBodyMouseEvent('mouseup');
-      assert.strictEqual(handleClickAway.callCount, 0);
-      const mouseDownEvent = fireBodyMouseEvent('mousedown');
-      assert.strictEqual(handleClickAway.callCount, 1);
-      assert.deepEqual(handleClickAway.args[0], [mouseDownEvent]);
+      fireEvent.mouseUp(document.body);
+      expect(handleClickAway.callCount).to.equal(0);
+      fireEvent.mouseDown(document.body);
+      expect(handleClickAway.callCount).to.equal(1);
+      expect(handleClickAway.args[0].length).to.equal(1);
     });
   });
 
   describe('prop: touchEvent', () => {
     it('should not call `props.onClickAway` when `props.touchEvent` is `false`', () => {
       const handleClickAway = spy();
-      wrapper = mount(
+      render(
         <ClickAwayListener onClickAway={handleClickAway} touchEvent={false}>
-          <span>Hello</span>
+          <span />
         </ClickAwayListener>,
       );
-      fireBodyMouseEvent('touchend');
-      assert.strictEqual(handleClickAway.callCount, 0);
+      fireEvent.touchEnd(document.body);
+      expect(handleClickAway.callCount).to.equal(0);
     });
 
     it('should call `props.onClickAway` when the appropriate touch event is triggered', () => {
       const handleClickAway = spy();
-      wrapper = mount(
+      render(
         <ClickAwayListener onClickAway={handleClickAway} touchEvent="onTouchStart">
-          <span>Hello</span>
+          <span />
         </ClickAwayListener>,
       );
-      fireBodyMouseEvent('touchend');
-      assert.strictEqual(handleClickAway.callCount, 0);
-      const touchStartEvent = fireBodyMouseEvent('touchstart');
-      assert.strictEqual(handleClickAway.callCount, 1);
-      assert.deepEqual(handleClickAway.args[0], [touchStartEvent]);
+      fireEvent.touchEnd(document.body);
+      expect(handleClickAway.callCount).to.equal(0);
+      fireEvent.touchStart(document.body);
+      expect(handleClickAway.callCount).to.equal(1);
+      expect(handleClickAway.args[0].length).to.equal(1);
     });
 
     it('should ignore `touchend` when preceded by `touchmove` event', () => {
       const handleClickAway = spy();
-      wrapper = mount(
+      render(
         <ClickAwayListener onClickAway={handleClickAway} touchEvent="onTouchEnd">
-          <span>Hello</span>
+          <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], [touchEndEvent]);
+      fireEvent.touchStart(document.body);
+      fireEvent.touchMove(document.body);
+      fireEvent.touchEnd(document.body);
+      expect(handleClickAway.callCount).to.equal(0);
+
+      fireEvent.touchEnd(document.body);
+      expect(handleClickAway.callCount).to.equal(1);
+      expect(handleClickAway.args[0].length).to.equal(1);
     });
   });
 
   it('should handle null child', () => {
     const Child = React.forwardRef(() => null);
     const handleClickAway = spy();
-    wrapper = mount(
+    render(
       <ClickAwayListener onClickAway={handleClickAway}>
         <Child />
       </ClickAwayListener>,
     );
-    fireBodyMouseEvent('click');
-    assert.strictEqual(handleClickAway.callCount, 0);
+    fireEvent.click(document.body);
+    expect(handleClickAway.callCount).to.equal(0);
   });
 });