Repository: mui/material-ui (TypeScript)
Date: 2020-04-26 22:18:34+00:00
Task type: Bug Fix

Issue:
[Modal] Should not call stopPropagation if onEscapeKeyDown=true

I'm running into an issue where I need to handle the event propagation for the escape key on my own. It's impossible to get around because it's hard-coded in the handleKeyDown callback.

To be specific, I have a Popper rendered (via portal) over the modal, and when a user hits the Escape key, I want to close the Popper, but not the modal.


- [-] The issue is present in the latest release. (not sure; cannot upgrade)
- [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.

## Current Behavior 😯
When passing onEscapeKeyDown and disableEscapeKeyDown props to Modal, I do not want the event.stopPropagation to be called. 

Instead of allowing the onEscapeKeyDown to handle the event propagation; the escape keydown event's propagation is stopped.

## Expected Behavior 🤔
When modal is provided onEscapeKeyDown, a develop should expect to handle all aspects of the event.  What also might be nice is if the modal manager is passed as a second parameter to the callback.

| Tech        | Version |
| ----------- | ------- |
| Material-UI | v4.4.3  |



Issue discussion:
@mentierd It seems that you are right, the current logic doesn't match the props' descriptions. What do you think of this diff? Do you want to submit a pull request? :)

```diff
diff --git a/packages/material-ui/src/Modal/Modal.js b/packages/material-ui/src/Modal/Modal.js
index badf67093..dc8eb6cee 100644
--- a/packages/material-ui/src/Modal/Modal.js
+++ b/packages/material-ui/src/Modal/Modal.js
@@ -189,7 +189,7 @@ const Modal = React.forwardRef(function Modal(inProps, ref) {
     // 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.
-    if (event.key !== 'Escape' || !isTopModal()) {
+    if (event.key !== 'Escape' || !isTopModal() || disableEscapeKeyDown) {
       return;
     }

@@ -200,7 +200,7 @@ const Modal = React.forwardRef(function Modal(inProps, ref) {
       onEscapeKeyDown(event);
     }

-    if (!disableEscapeKeyDown && onClose) {
+    if (onClose) {
       onClose(event, 'escapeKeyDown');
     }
   };
```

For curiosity, I have looked at how the problem is handled in a couple of alternatives:

- https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/js/dialog.js
- https://github.com/twbs/bootstrap/blob/a8883a3b96a94d9c096d1047564eeb87de7e79e7/js/src/modal.js#L301
- https://github.com/reactjs/react-modal/blob/5189e5b6928319a831edad27cecd964ebd59ec33/src/components/ModalPortal.js#L266
- https://github.com/reach/reach-ui/blob/d5c59b90c6d32c7640d7ae1e6a40ab92ef033800/packages/dialog/src/index.tsx#L118
I'd do it if @mentierd is too busy this weekend :grinning: 
@oliviertassinari I tried to implement your solution but one test fail:

```javascript
it('when disableEscapeKeyDown should call only onEscapeKeyDownSpy', () => {
```

What do you think about this diff?
```diff
diff --git a/packages/material-ui/src/Modal/Modal.js b/packages/material-ui/src/Modal/Modal.js
index 1c38d39c4..748a70ec1 100644
--- a/packages/material-ui/src/Modal/Modal.js
+++ b/packages/material-ui/src/Modal/Modal.js
@@ -193,11 +193,11 @@ const Modal = React.forwardRef(function Modal(inProps, ref) {
       return;
     }
 
-    // Swallow the event, in case someone is listening for the escape key on the body.
-    event.stopPropagation();
-
     if (onEscapeKeyDown) {
       onEscapeKeyDown(event);
+    } else {
+      // Swallow the event, in case someone is listening for the escape key on the body.
+      event.stopPropagation();
     }
 
     if (!disableEscapeKeyDown && onClose) {
```
@weslenng The test goes against the documentation: ``If `true`, hitting escape will not fire any callback`` but it's the same issue with `disableBackdropClick`. Ok, I think that your alternative proposal is great.

For v5, I think that we can drop `disableBackdropClick`, `onBackdropClick`, `onEscapeKeyDown`, `disableEscapeKeyDown`. onChange + reason + filtering should be enough. 

Solution patch:
diff --git a/docs/pages/api-docs/modal.md b/docs/pages/api-docs/modal.md
--- a/docs/pages/api-docs/modal.md
+++ b/docs/pages/api-docs/modal.md
@@ -42,9 +42,9 @@ This component shares many concepts with [react-overlays](https://react-bootstra
 | <span class="prop-name">closeAfterTransition</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | When set to true the Modal waits until a nested Transition is completed before closing. |
 | <span class="prop-name">container</span> | <span class="prop-type">HTML element<br>&#124;&nbsp;React.Component<br>&#124;&nbsp;func</span> |  | A HTML element, component instance, or function that returns either. The `container` will have the portal children appended to it.<br>By default, it uses the body of the top-level document object, so it's simply `document.body` most of the time. |
 | <span class="prop-name">disableAutoFocus</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the modal will not automatically shift focus to itself when it opens, and replace it to the last focused element when it closes. This also works correctly with any modal children that have the `disableAutoFocus` prop.<br>Generally this should never be set to `true` as it makes the modal less accessible to assistive technologies, like screen readers. |
-| <span class="prop-name">disableBackdropClick</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, clicking the backdrop will not fire any callback. |
+| <span class="prop-name">disableBackdropClick</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, clicking the backdrop will not fire `onClose`. |
 | <span class="prop-name">disableEnforceFocus</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the modal will not prevent focus from leaving the modal while open.<br>Generally this should never be set to `true` as it makes the modal less accessible to assistive technologies, like screen readers. |
-| <span class="prop-name">disableEscapeKeyDown</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, hitting escape will not fire any callback. |
+| <span class="prop-name">disableEscapeKeyDown</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, hitting escape will not fire `onClose`. |
 | <span class="prop-name">disablePortal</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | Disable the portal behavior. The children stay within it's parent DOM hierarchy. |
 | <span class="prop-name">disableRestoreFocus</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the modal will not restore focus to previously focused element once modal is hidden. |
 | <span class="prop-name">disableScrollLock</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | Disable the scroll lock behavior. |
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
@@ -193,15 +193,17 @@ const Modal = React.forwardRef(function Modal(inProps, ref) {
       return;
     }
 
-    // Swallow the event, in case someone is listening for the escape key on the body.
-    event.stopPropagation();
-
     if (onEscapeKeyDown) {
       onEscapeKeyDown(event);
     }
 
-    if (!disableEscapeKeyDown && onClose) {
-      onClose(event, 'escapeKeyDown');
+    if (!disableEscapeKeyDown) {
+      // Swallow the event, in case someone is listening for the escape key on the body.
+      event.stopPropagation();
+
+      if (onClose) {
+        onClose(event, 'escapeKeyDown');
+      }
     }
   };
 
@@ -294,7 +296,7 @@ Modal.propTypes = {
    */
   disableAutoFocus: PropTypes.bool,
   /**
-   * If `true`, clicking the backdrop will not fire any callback.
+   * If `true`, clicking the backdrop will not fire `onClose`.
    */
   disableBackdropClick: PropTypes.bool,
   /**
@@ -305,7 +307,7 @@ Modal.propTypes = {
    */
   disableEnforceFocus: PropTypes.bool,
   /**
-   * If `true`, hitting escape will not fire any callback.
+   * If `true`, hitting escape will not fire `onClose`.
    */
   disableEscapeKeyDown: PropTypes.bool,
   /**


Test patch:
diff --git a/packages/material-ui/src/ButtonBase/ButtonBase.test.js b/packages/material-ui/src/ButtonBase/ButtonBase.test.js
--- a/packages/material-ui/src/ButtonBase/ButtonBase.test.js
+++ b/packages/material-ui/src/ButtonBase/ButtonBase.test.js
@@ -830,7 +830,7 @@ describe('<ButtonBase />', () => {
       });
 
       it('does not call onClick if Space was released on a child', () => {
-        const onClickSpy = spy((event) => event.defaultPrevented);
+        const onClickSpy = spy();
         const onKeyUpSpy = spy();
         render(
           <ButtonBase onClick={onClickSpy} onKeyUp={onKeyUpSpy} component="div">
diff --git a/packages/material-ui/src/Modal/Modal.test.js b/packages/material-ui/src/Modal/Modal.test.js
--- a/packages/material-ui/src/Modal/Modal.test.js
+++ b/packages/material-ui/src/Modal/Modal.test.js
@@ -270,12 +270,15 @@ describe('<Modal />', () => {
     });
 
     it('should call onEscapeKeyDown and onClose', () => {
+      const handleKeyDown = spy();
       const onEscapeKeyDownSpy = spy();
       const onCloseSpy = spy();
       const { getByTestId } = render(
-        <Modal data-testid="modal" open onEscapeKeyDown={onEscapeKeyDownSpy} onClose={onCloseSpy}>
-          <div />
-        </Modal>,
+        <div onKeyDown={handleKeyDown}>
+          <Modal data-testid="modal" open onEscapeKeyDown={onEscapeKeyDownSpy} onClose={onCloseSpy}>
+            <div />
+          </Modal>
+        </div>,
       );
       getByTestId('modal').focus();
 
@@ -285,21 +288,25 @@ describe('<Modal />', () => {
 
       expect(onEscapeKeyDownSpy).to.have.property('callCount', 1);
       expect(onCloseSpy).to.have.property('callCount', 1);
+      expect(handleKeyDown).to.have.property('callCount', 0);
     });
 
-    it('when disableEscapeKeyDown should call only onEscapeKeyDownSpy', () => {
+    it('should not call onChange when `disableEscapeKeyDown=true`', () => {
+      const handleKeyDown = spy();
       const onEscapeKeyDownSpy = spy();
       const onCloseSpy = spy();
       const { getByTestId } = render(
-        <Modal
-          data-testid="modal"
-          open
-          disableEscapeKeyDown
-          onEscapeKeyDown={onEscapeKeyDownSpy}
-          onClose={onCloseSpy}
-        >
-          <div />
-        </Modal>,
+        <div onKeyDown={handleKeyDown}>
+          <Modal
+            data-testid="modal"
+            open
+            disableEscapeKeyDown
+            onEscapeKeyDown={onEscapeKeyDownSpy}
+            onClose={onCloseSpy}
+          >
+            <div />
+          </Modal>
+        </div>,
       );
       getByTestId('modal').focus();
 
@@ -309,6 +316,7 @@ describe('<Modal />', () => {
 
       expect(onEscapeKeyDownSpy).to.have.property('callCount', 1);
       expect(onCloseSpy).to.have.property('callCount', 0);
+      expect(handleKeyDown).to.have.property('callCount', 1);
     });
   });