Repository: mui/material-ui (TypeScript)
Date: 2018-07-19 22:49:47+00:00
Task type: Feature

Issue:
[Drawer] Close persistent drawer on Escape keydown
Hi!

I want to make right `persistent` drawer (like in [three pane interfaces](https://en.wikipedia.org/wiki/Three-pane_interface)). Also I want to close right pane by <kbd >Escape</kbd> pressed. But this keypress works only for `temporary` drawers based on `Modal` components.

- [x] This is a v1.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.

## Current Behavior
I could add my own `keydown` listener and close my drawer, but I cannot to check that some `Modal` is opened for properly closing (`Escape` closes my drawer and opened modal).

I cannot use `temporary` drawer for this because it creates fixed `div` that fill whole page and blocks other elements on page.

## Expected Behavior
As the simplest solution I would to suggest to export default `ModalManager` singleton from `@material-ui/core/Modal` (`Modal.defaultProps.manager`) and add to it some method like `hasModals()`.

## Examples

```ts
import * as React from 'react';
import { manager } from '@material-ui/core/Modal';
import Drawer from '@material-ui/core/Drawer';
import * as EventListener from 'react-event-listener';

interface Props {}

interface State {
  drawerOpen: boolean;
}

class MyComponent extends React.Component<Props, State> {
  public state: State = {
    drawerOpen: false,
  };

  private handleDocumentKeyDown = (event: KeyboardEvent) => {
    if (event.key === 'Escape' && !manager.hasModal()) {
      this.setState({ drawerOpen: false });
    }
  };

  public render() {
    return (
      <React.Fragment>
        <EventListener target="document" onKeydown={this.handleDocumentKeyDown}/>
        <Drawer variant="persistent" open={this.state.drawerOpen} />
      </React.Fragment>
    );
  }
}
```



Issue discussion:
@mctep I would suggest you to use a custom modalManager instance, providing it to all the modals you are using (you can use theme for that). Then, you can use the `isTopModal()` method to know if you have to handle the <kbd>Escape</kbd> event.

Also, I'm wondering if we should be checking `event.defaultPrevented` in the Modal keyboard handler to prune the handler?
@oliviertassinari Thank you for the answer! So I need to wrap all components that use `Modal` component. It is `Popover`, `Menu`, `Drawer`. I see it is OK but a bit of a hassle 😬. Is it probably better to add special `ModalManagerProvider`?

> Also, I'm wondering if we should be checking `event.defaultPrevented` in the Modal keyboard handler to prune the handler?

Sorry, but I do not understand. How does it help with the issue? I do not need to prune the `Modal` handler. I need to prune my handler. Or do you mean to call `preventDefault` in the `Modal` handler? But there is no guaranty that handlers will be called in the right order.
> Is it probably better to add special ModalManagerProvider?

Yes, as I was saying it earlier, you can use the theme for that: (`theme.props.MuiModal.modalManager`).

> Sorry, but I do not understand. How does it help with the issue?

You could be listening for the <kbd>Escape</kbd> on your custom drawer component. You could prevent the default event behavior, then the Material-UI's Modal could check if the event default behavior is prevented and skip the close logic.
> `theme.props.MuiModal.modalManager`

Awesome! I didn't know about this API. I'll try it. Thank you.

> Modal could check if the event default behavior is prevented and skip the close logic.

`Modal`'s property `disableEscapeKeyDown` is used for this purpose, isn't it?

Theme works like a charm! :sparkles:

**The best library ever!**

Solution patch:
diff --git a/packages/material-ui/src/ButtonBase/createRippleHandler.js b/packages/material-ui/src/ButtonBase/createRippleHandler.js
--- a/packages/material-ui/src/ButtonBase/createRippleHandler.js
+++ b/packages/material-ui/src/ButtonBase/createRippleHandler.js
@@ -6,6 +6,7 @@ function createRippleHandler(instance, eventName, action, cb) {
 
     let ignore = false;
 
+    // Ignore events that have been `event.preventDefault()` marked.
     if (event.defaultPrevented) {
       ignore = true;
     }
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
@@ -157,6 +157,11 @@ class Modal extends React.Component {
       return;
     }
 
+    // Ignore events that have been `event.preventDefault()` marked.
+    if (event.defaultPrevented) {
+      return;
+    }
+
     if (this.props.onEscapeKeyDown) {
       this.props.onEscapeKeyDown(event);
     }


Test patch:
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
@@ -249,24 +249,19 @@ describe('<Modal />', () => {
   describe('handleDocumentKeyDown()', () => {
     let wrapper;
     let instance;
-    let onEscapeKeyDownStub;
-    let onCloseStub;
+    let onEscapeKeyDownSpy;
+    let onCloseSpy;
     let topModalStub;
     let event;
 
     beforeEach(() => {
-      wrapper = shallow(<Modal open={false} />);
-      instance = wrapper.instance();
-      onEscapeKeyDownStub = stub().returns(true);
-      onCloseStub = stub().returns(true);
+      onEscapeKeyDownSpy = spy();
+      onCloseSpy = spy();
       topModalStub = stub();
-      wrapper.setProps({ onEscapeKeyDown: onEscapeKeyDownStub, onClose: onCloseStub });
-    });
-
-    afterEach(() => {
-      onEscapeKeyDownStub.reset();
-      onCloseStub.reset();
-      topModalStub.reset();
+      wrapper = shallow(
+        <Modal open={false} onEscapeKeyDown={onEscapeKeyDownSpy} onClose={onCloseSpy} />,
+      );
+      instance = wrapper.instance();
     });
 
     it('should have handleDocumentKeyDown', () => {
@@ -275,66 +270,66 @@ describe('<Modal />', () => {
     });
 
     it('when not mounted should not call onEscapeKeyDown and onClose', () => {
-      instance = wrapper.instance();
-      instance.mounted = false;
       instance.handleDocumentKeyDown(undefined);
-      assert.strictEqual(onEscapeKeyDownStub.callCount, 0);
-      assert.strictEqual(onCloseStub.callCount, 0);
+      assert.strictEqual(onEscapeKeyDownSpy.callCount, 0);
+      assert.strictEqual(onCloseSpy.callCount, 0);
     });
 
     it('when mounted and not TopModal should not call onEscapeKeyDown and onClose', () => {
-      topModalStub.returns('false');
+      topModalStub.returns(false);
       wrapper.setProps({ manager: { isTopModal: topModalStub } });
-      instance = wrapper.instance();
-      instance.mounted = true;
 
       instance.handleDocumentKeyDown(undefined);
       assert.strictEqual(topModalStub.callCount, 1);
-      assert.strictEqual(onEscapeKeyDownStub.callCount, 0);
-      assert.strictEqual(onCloseStub.callCount, 0);
+      assert.strictEqual(onEscapeKeyDownSpy.callCount, 0);
+      assert.strictEqual(onCloseSpy.callCount, 0);
     });
 
     it('when mounted, TopModal and event not esc should not call given funcs', () => {
       topModalStub.returns(true);
       wrapper.setProps({ manager: { isTopModal: topModalStub } });
-      instance = wrapper.instance();
-      instance.mounted = true;
       event = { keyCode: keycode('j') }; // Not 'esc'
 
       instance.handleDocumentKeyDown(event);
       assert.strictEqual(topModalStub.callCount, 1);
-      assert.strictEqual(onEscapeKeyDownStub.callCount, 0);
-      assert.strictEqual(onCloseStub.callCount, 0);
+      assert.strictEqual(onEscapeKeyDownSpy.callCount, 0);
+      assert.strictEqual(onCloseSpy.callCount, 0);
     });
 
     it('should call onEscapeKeyDown and onClose', () => {
       topModalStub.returns(true);
       wrapper.setProps({ manager: { isTopModal: topModalStub } });
       event = { keyCode: keycode('esc') };
-      instance = wrapper.instance();
-      instance.mounted = true;
 
       instance.handleDocumentKeyDown(event);
       assert.strictEqual(topModalStub.callCount, 1);
-      assert.strictEqual(onEscapeKeyDownStub.callCount, 1);
-      assert.strictEqual(onEscapeKeyDownStub.calledWith(event), true);
-      assert.strictEqual(onCloseStub.callCount, 1);
-      assert.strictEqual(onCloseStub.calledWith(event), true);
+      assert.strictEqual(onEscapeKeyDownSpy.callCount, 1);
+      assert.strictEqual(onEscapeKeyDownSpy.calledWith(event), true);
+      assert.strictEqual(onCloseSpy.callCount, 1);
+      assert.strictEqual(onCloseSpy.calledWith(event), true);
     });
 
     it('when disableEscapeKeyDown should call only onClose', () => {
       topModalStub.returns(true);
-      wrapper.setProps({ manager: { isTopModal: topModalStub } });
-      wrapper.setProps({ disableEscapeKeyDown: true });
+      wrapper.setProps({ disableEscapeKeyDown: true, manager: { isTopModal: topModalStub } });
       event = { keyCode: keycode('esc') };
-      instance = wrapper.instance();
-      instance.mounted = true;
 
       instance.handleDocumentKeyDown(event);
       assert.strictEqual(topModalStub.callCount, 1);
-      assert.strictEqual(onEscapeKeyDownStub.callCount, 1);
-      assert.strictEqual(onEscapeKeyDownStub.calledWith(event), true);
-      assert.strictEqual(onCloseStub.callCount, 0);
+      assert.strictEqual(onEscapeKeyDownSpy.callCount, 1);
+      assert.strictEqual(onEscapeKeyDownSpy.calledWith(event), true);
+      assert.strictEqual(onCloseSpy.callCount, 0);
+    });
+
+    it('should not be call when defaultPrevented', () => {
+      topModalStub.returns(true);
+      wrapper.setProps({ disableEscapeKeyDown: true, manager: { isTopModal: topModalStub } });
+      event = { keyCode: keycode('esc'), defaultPrevented: true };
+
+      instance.handleDocumentKeyDown(event);
+      assert.strictEqual(topModalStub.callCount, 1);
+      assert.strictEqual(onEscapeKeyDownSpy.callCount, 0);
+      assert.strictEqual(onCloseSpy.callCount, 0);
     });
   });