Repository: mui/material-ui (TypeScript)
Date: 2019-07-12 10:48:40+00:00
Task type: Bug Fix

Issue:
[Modal] focus-trap fails when a focused element in the modal unmounts
- [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 🤔
The dialog should close after pressing ESC

## Current Behavior 😯
The dialog is not closed

## Steps to Reproduce 🕹
Link:
https://codesandbox.io/s/material-ui-dialog-close-by-esc-3ixsz

1. Click "OPEN DIALOG"
2. Click "CLICK ME"
3. Press Esc to close

## Context 🔦

## Your Environment 🌎

| Tech         | Version |
|--------------|---------|
| Material-UI  | v4.2.0  |
| React        |  v16.8.6  |
| Browser      | Chrome  Version 75.0.3770.100 (Official Build) (64-bit)   |

**There is only in Chrome, Safari and Firefox everything is fine**


Issue discussion:
I'd say the actual issue is that focus isn't trapped inside the dialog. Intuitively we could return focus from document.body to the focus trap to catch this specific issue. We "just" need to research event order and transition of document.activeElement. Something like "if any blur event is fired return to focus to focusTrap if the next active element is outside" 

Solution patch:
diff --git a/packages/material-ui/src/Modal/TrapFocus.js b/packages/material-ui/src/Modal/TrapFocus.js
--- a/packages/material-ui/src/Modal/TrapFocus.js
+++ b/packages/material-ui/src/Modal/TrapFocus.js
@@ -100,7 +100,18 @@ function TrapFocus(props) {
     doc.addEventListener('focus', contain, true);
     doc.addEventListener('keydown', loopFocus, true);
 
+    // With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area
+    // e.g. https://bugzilla.mozilla.org/show_bug.cgi?id=559561.
+    //
+    // The whatwg spec defines how the browser should behave but does not explicitly mention any events:
+    // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule.
+    const interval = setInterval(() => {
+      contain();
+    }, 50);
+
     return () => {
+      clearInterval(interval);
+
       doc.removeEventListener('focus', contain, true);
       doc.removeEventListener('keydown', loopFocus, true);
 


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
@@ -1,8 +1,9 @@
 import React from 'react';
 import { assert, expect } from 'chai';
-import { spy } from 'sinon';
+import { useFakeTimers, spy } from 'sinon';
 import PropTypes from 'prop-types';
 import consoleErrorMock from 'test/utils/consoleErrorMock';
+import { cleanup, createClientRender } from 'test/utils/createClientRender';
 import { createMount, findOutermostIntrinsic } from '@material-ui/core/test-utils';
 import describeConformance from '../test-utils/describeConformance';
 import Fade from '../Fade';
@@ -11,6 +12,7 @@ import Modal from './Modal';
 
 describe('<Modal />', () => {
   let mount;
+  const render = createClientRender({ strict: false });
   let savedBodyStyle;
 
   before(() => {
@@ -23,6 +25,10 @@ describe('<Modal />', () => {
     document.body.setAttribute('style', savedBodyStyle);
   });
 
+  afterEach(() => {
+    cleanup();
+  });
+
   after(() => {
     mount.cleanUp();
   });
@@ -504,6 +510,46 @@ describe('<Modal />', () => {
       });
       assert.strictEqual(document.activeElement.getAttribute('data-test'), 'sentinelEnd');
     });
+
+    describe('', () => {
+      let clock;
+
+      before(() => {
+        clock = useFakeTimers();
+      });
+
+      after(() => {
+        clock.restore();
+      });
+
+      it('contains the focus if the active element is removed', () => {
+        function WithRemovableElement({ hideButton = false }) {
+          return (
+            <Modal open>
+              <div>{!hideButton && <button type="button">I am going to disappear</button>}</div>
+            </Modal>
+          );
+        }
+        WithRemovableElement.propTypes = {
+          hideButton: PropTypes.bool,
+        };
+
+        wrapper = {
+          unmount() {},
+        };
+
+        const { getByRole, setProps } = render(<WithRemovableElement />);
+        expect(getByRole('document')).to.be.focused;
+
+        getByRole('button').focus();
+        expect(getByRole('button')).to.be.focused;
+
+        setProps({ hideButton: true });
+        expect(getByRole('document')).to.not.be.focused;
+        clock.tick(500); // wait for the interval check to kick in.
+        expect(getByRole('document')).to.be.focused;
+      });
+    });
   });
 
   describe('prop: onRendered', () => {
diff --git a/packages/material-ui/src/Popover/Popover.test.js b/packages/material-ui/src/Popover/Popover.test.js
--- a/packages/material-ui/src/Popover/Popover.test.js
+++ b/packages/material-ui/src/Popover/Popover.test.js
@@ -609,6 +609,8 @@ describe('<Popover />', () => {
     let wrapper;
 
     before(() => {
+      clock = useFakeTimers();
+
       innerHeightContainer = window.innerHeight;
       const mockedAnchor = document.createElement('div');
       stub(mockedAnchor, 'getBoundingClientRect').callsFake(() => ({
@@ -629,8 +631,6 @@ describe('<Popover />', () => {
         </Popover>,
       );
       element = handleEntering.args[0][0];
-
-      clock = useFakeTimers();
     });
 
     after(() => {