diff --git a/packages/react-native-web/src/exports/Modal/ModalContent.js b/packages/react-native-web/src/exports/Modal/ModalContent.js index 0ce3ee493c..07b94befee 100644 --- a/packages/react-native-web/src/exports/Modal/ModalContent.js +++ b/packages/react-native-web/src/exports/Modal/ModalContent.js @@ -29,18 +29,34 @@ const ModalContent: React.AbstractComponent< > = React.forwardRef((props, forwardedRef) => { const { active, children, onRequestClose, transparent, ...rest } = props; + const escapeKeyDownRef = React.useRef(null); + React.useEffect(() => { if (canUseDOM) { + const recordEscapeKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !e.repeat) { + escapeKeyDownRef.current = active ? e : null; + } + }; const closeOnEscape = (e: KeyboardEvent) => { - if (active && e.key === 'Escape') { + if (e.key !== 'Escape') { + return; + } + const keyDown = escapeKeyDownRef.current; + escapeKeyDownRef.current = null; + if (active && keyDown != null && !keyDown.defaultPrevented) { e.stopPropagation(); if (onRequestClose) { onRequestClose(); } } }; + document.addEventListener('keydown', recordEscapeKeyDown, true); document.addEventListener('keyup', closeOnEscape, false); - return () => document.removeEventListener('keyup', closeOnEscape, false); + return () => { + document.removeEventListener('keydown', recordEscapeKeyDown, true); + document.removeEventListener('keyup', closeOnEscape, false); + }; } }, [active, onRequestClose]); diff --git a/packages/react-native-web/src/exports/Modal/__tests__/index-test.js b/packages/react-native-web/src/exports/Modal/__tests__/index-test.js index 9e82fcc5ba..f781e6cc1a 100644 --- a/packages/react-native-web/src/exports/Modal/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Modal/__tests__/index-test.js @@ -7,8 +7,14 @@ import Modal from '..'; import React from 'react'; +import TextInput from '../../TextInput'; import { fireEvent, render } from '@testing-library/react'; +function pressEscape(target) { + fireEvent.keyDown(target, { key: 'Escape' }); + fireEvent.keyUp(target, { key: 'Escape' }); +} + describe('components/Modal', () => { test('visible by default', () => { const { getByTestId } = render( @@ -616,7 +622,7 @@ describe('components/Modal', () => { render(); - fireEvent.keyUp(document, { key: 'Escape' }); + pressEscape(document); expect(spy).toHaveBeenCalledTimes(1); }); @@ -632,7 +638,7 @@ describe('components/Modal', () => { ); - fireEvent.keyUp(document, { key: 'Escape' }); + pressEscape(document); expect(spyA).toHaveBeenCalledTimes(0); expect(spyB).toHaveBeenCalledTimes(1); @@ -675,9 +681,148 @@ describe('components/Modal', () => { fireEvent.animationEnd(animationAElement); fireEvent.animationEnd(animationBElement); + pressEscape(document); + + expect(spyA).toHaveBeenCalledTimes(0); + expect(spyB).toHaveBeenCalledTimes(1); + }); + + test('escape key fires onRequestClose when a TextInput is focused', () => { + const spy = jest.fn(); + + const { getByTestId } = render( + + + + ); + + pressEscape(getByTestId('input')); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('escape key closes one modal per key press', () => { + const spyA = jest.fn(); + const spyB = jest.fn(); + + function TestComponent() { + const [visibleB, setVisibleB] = React.useState(true); + return ( + <> + + { + spyB(); + setVisibleB(false); + }} + visible={visibleB} + /> + + ); + } + + render(); + + pressEscape(document); + + expect(spyA).toHaveBeenCalledTimes(0); + expect(spyB).toHaveBeenCalledTimes(1); + }); + + test('escape key closes one modal per key press when the app handles it', () => { + const spyA = jest.fn(); + const spyB = jest.fn(); + + function TestComponent() { + const [visibleB, setVisibleB] = React.useState(true); + return ( + <> + + + { + if (e.key === 'Escape') { + e.preventDefault(); + setVisibleB(false); + } + }} + /> + + + ); + } + + const { getByTestId } = render(); + + fireEvent.keyDown(getByTestId('b'), { key: 'Escape' }); + fireEvent.keyUp(document, { key: 'Escape' }); + + expect(spyA).toHaveBeenCalledTimes(0); + expect(spyB).toHaveBeenCalledTimes(0); + }); + + test('holding escape closes one modal per key press', () => { + const spyA = jest.fn(); + const spyB = jest.fn(); + + function TestComponent() { + const [visibleB, setVisibleB] = React.useState(true); + return ( + <> + + { + spyB(); + setVisibleB(false); + }} + visible={visibleB} + /> + + ); + } + + render(); + + fireEvent.keyDown(document, { key: 'Escape' }); + fireEvent.keyDown(document, { key: 'Escape', repeat: true }); + fireEvent.keyDown(document, { key: 'Escape', repeat: true }); fireEvent.keyUp(document, { key: 'Escape' }); expect(spyA).toHaveBeenCalledTimes(0); expect(spyB).toHaveBeenCalledTimes(1); }); + + test('escape key does not fire onRequestClose when the keydown was handled', () => { + const spy = jest.fn(); + + const { getByTestId } = render( + + { + if (e.key === 'Escape') { + e.preventDefault(); + } + }} + /> + + ); + + pressEscape(getByTestId('a')); + + expect(spy).toHaveBeenCalledTimes(0); + }); + + test('escape key does not fire onRequestClose for a key press that began before the modal was active', () => { + const spy = jest.fn(); + + const { rerender } = render(); + + fireEvent.keyDown(document, { key: 'Escape' }); + rerender(); + fireEvent.keyUp(document, { key: 'Escape' }); + + expect(spy).toHaveBeenCalledTimes(0); + }); });