diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 9b73d648ed..0fe463fa8f 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -219,7 +219,23 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`, ``` -Previously, the Android back button dismissed the modal when `dismissable` was `true`, even if `dismissableBackButton` was `false`. The `dismissableBackButton` prop can now prevent the modal from being dismissed via the back button independently of the `dismissable` prop. +`dismissable` controls the Android back button, the Escape key, the screen reader's escape gesture, and the dismiss button described above. An outside tap is controlled by the new `dismissableOverlay` prop, which applies only when the modal is `dismissable`. The `dismissableBackButton` prop was removed. Setting `dismissable={false}` blocks every way of dismissing the modal. + +To ignore an outside tap and still allow the back button, the Escape key, and the screen reader's escape gesture to close the modal, use `dismissableOverlay={false}`: + +```diff + + Content + +``` + +`Dialog` uses the same `dismissable` and `dismissableOverlay` props. ### Dialog @@ -246,7 +262,7 @@ The dialog now has the `dialog` role. On web, the dialog's accessible name is se ``` -When the dialog is `dismissable`, screen reader users can dismiss it with a visually hidden button inside the dialog. You can change the button's accessibility label with the new `dismissAccessibilityLabel` prop. +When the dialog is `dismissable`, screen reader users can dismiss it with a visually hidden button inside the dialog. You can change the button's accessibility label with the new `dismissAccessibilityLabel` prop. `dismissable` and `dismissableOverlay` behave the same way as on `Modal`, and `dismissableBackButton` was removed. - The default elevation changed from level `1` to level `3`. - The `style` prop no longer configures the background color or border radius. You can override `theme.colors.surfaceContainerHigh` and `theme.shapes.corner.extraLarge` using the `theme` prop instead. diff --git a/example/src/Examples/DialogExample.tsx b/example/src/Examples/DialogExample.tsx index b12cbb5c84..71e6fd8f04 100644 --- a/example/src/Examples/DialogExample.tsx +++ b/example/src/Examples/DialogExample.tsx @@ -1,16 +1,15 @@ import * as React from 'react'; -import { Platform, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import { Button } from 'react-native-paper'; import { DialogWithCustomColors, - DialogWithDismissableBackButton, DialogWithIcon, DialogWithLoadingIndicator, DialogWithLongText, DialogWithRadioBtns, - DialogWithUndismissableBackButton, + DialogWithUndismissableOverlay, UndismissableDialog, } from './Dialogs'; import ScreenWrapper from '../ScreenWrapper'; @@ -71,24 +70,13 @@ const DialogExample = () => { > With icon - {Platform.OS === 'android' && ( - - )} - {Platform.OS === 'android' && ( - - )} + { visible={_getVisible('dialog6')} close={_toggleDialog('dialog6')} /> - - ); }; diff --git a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx deleted file mode 100644 index d7a37e5569..0000000000 --- a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, Dialog, Palette } from 'react-native-paper'; - -import { TextComponent } from './DialogTextComponent'; - -const DialogWithDismissableBackButton = ({ - visible, - close, -}: { - visible: boolean; - close: () => void; -}) => ( - - Alert - - - This is an undismissable dialog, however you can use hardware back - button to close it! - - - - - - - -); - -export default DialogWithDismissableBackButton; diff --git a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithUndismissableOverlay.tsx similarity index 63% rename from example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx rename to example/src/Examples/Dialogs/DialogWithUndismissableOverlay.tsx index d775ba011c..7e9998368c 100644 --- a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx +++ b/example/src/Examples/Dialogs/DialogWithUndismissableOverlay.tsx @@ -2,19 +2,19 @@ import { Button, Dialog, Palette } from 'react-native-paper'; import { TextComponent } from './DialogTextComponent'; -const DialogWithUndismissableBackButton = ({ +const DialogWithUndismissableOverlay = ({ visible, close, }: { visible: boolean; close: () => void; }) => ( - + Alert - This dialog can be dismissed by tapping outside, however the hardware - back button will not close it! + Tapping outside will not close this dialog, however you can still close + it with the back button or the Escape key! @@ -26,4 +26,4 @@ const DialogWithUndismissableBackButton = ({ ); -export default DialogWithUndismissableBackButton; +export default DialogWithUndismissableOverlay; diff --git a/example/src/Examples/Dialogs/index.tsx b/example/src/Examples/Dialogs/index.tsx index 5ae48db11f..cee460952c 100644 --- a/example/src/Examples/Dialogs/index.tsx +++ b/example/src/Examples/Dialogs/index.tsx @@ -4,5 +4,4 @@ export { default as DialogWithLongText } from './DialogWithLongText'; export { default as DialogWithRadioBtns } from './DialogWithRadioBtns'; export { default as UndismissableDialog } from './UndismissableDialog'; export { default as DialogWithIcon } from './DialogWithIcon'; -export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton'; -export { default as DialogWithUndismissableBackButton } from './DialogWithUndismissableBackButton'; +export { default as DialogWithUndismissableOverlay } from './DialogWithUndismissableOverlay'; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 5b273151ec..0a550bf02e 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -18,13 +18,15 @@ import type { DialogChildProps } from './utils'; export type Props = { /** - * Determines whether clicking outside the dialog dismiss it. + * Determines whether the user can dismiss the dialog with the Android back button, + * the Escape key, or the screen reader's escape gesture. */ dismissable?: boolean; /** - * Determines whether clicking Android hardware back button dismiss dialog. + * Determines whether clicking outside the dialog dismisses it. + * Applies only when the dialog is `dismissable`. */ - dismissableBackButton?: boolean; + dismissableOverlay?: boolean; /** * Callback that is called when the user dismisses the dialog. */ @@ -102,7 +104,7 @@ const DIALOG_ELEVATION: Elevation = 3; const Dialog = ({ children, dismissable = true, - dismissableBackButton = dismissable, + dismissableOverlay = true, onDismiss, dismissAccessibilityLabel, 'aria-label': ariaLabel, @@ -126,7 +128,7 @@ const Dialog = ({ aria-label={ariaLabel} aria-labelledby={ariaLabel == null ? titleId : undefined} dismissable={dismissable} - dismissableBackButton={dismissableBackButton} + dismissableOverlay={dismissableOverlay} onDismiss={onDismiss} dismissAccessibilityLabel={dismissAccessibilityLabel} visible={visible} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 9e1ab8a821..06b28d4105 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -15,20 +15,20 @@ import type { Props as SurfaceProps, SurfaceStyle } from './Surface'; import { useInternalTheme } from '../core/theming'; import { tokens } from '../theme/tokens'; import type { Elevation, ThemeProp } from '../theme/types'; -import { addEventListener } from '../utils/addEventListener'; -import { BackHandler } from '../utils/BackHandler/BackHandler'; const scrimAlpha = tokens.md.sys.scrim.alpha; export type Props = { /** - * Determines whether clicking outside the modal dismisses it. + * Determines whether the user can dismiss the modal with the Android back button, + * the Escape key, or the screen reader's escape gesture. */ dismissable?: boolean; /** - * Determines whether clicking Android hardware back button dismisses the dialog. + * Determines whether clicking outside the modal dismisses it. + * Applies only when the modal is `dismissable`. */ - dismissableBackButton?: boolean; + dismissableOverlay?: boolean; /** * Callback that is called when the user dismisses the modal. */ @@ -136,7 +136,7 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable); */ function Modal({ dismissable = true, - dismissableBackButton = dismissable, + dismissableOverlay = true, visible = false, dismissAccessibilityLabel = 'Close modal', 'aria-label': ariaLabel, @@ -186,28 +186,6 @@ function Modal({ return () => clearTimeout(timeout); }, [scale, visible, visibleInternal]); - React.useEffect(() => { - if (!visible) { - return undefined; - } - - const onHardwareBackPress = () => { - if (dismissableBackButton) { - onDismissCallback(); - } - - return true; - }; - - const subscription = addEventListener( - BackHandler, - 'hardwareBackPress', - onHardwareBackPress - ); - - return () => subscription.remove(); - }, [dismissableBackButton, onDismissCallback, visible]); - const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); const backdropTransitionStyle: AnimatedStyle = { @@ -234,21 +212,27 @@ function Modal({ return null; } + const overlayDismisses = dismissable && dismissableOverlay; + return ( - + diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index b11c1c6434..c2ee421977 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -17,11 +17,22 @@ export type Props = { */ children: React.ReactNode; /** - * Whether the portal hides items below it from screen readers and focus order. + * Whether the portal hides items below it from screen readers and focus order, + * and listens for the Android back button, the Escape key on web + * and the screen reader's escape gesture. * * Ensure it's set to true only when the modal is open. */ modal?: boolean; + /** + * Callback that is called when the user asks to close a `modal` portal. + * Only the topmost `modal` portal in a `Portal.Host` can be dismissed. + */ + onDismiss?: () => void; + /** + * Whether the user can dismiss a `modal` portal. + */ + dismissable?: boolean; /** * @optional */ @@ -34,6 +45,9 @@ export type Props = { * It requires a [`Portal.Host`](PortalHost) component to be rendered somewhere in the parent tree. * Note that if you're using the `Provider` component, this already includes a `Portal.Host`. * + * A topmost `modal` portal can be closed with the Android back button, the Escape key on web + * and the screen reader's escape gesture through `onDismiss`. + * * ## Usage * ```js * import * as React from 'react'; @@ -48,7 +62,13 @@ export type Props = { * export default MyComponent; * ``` */ -const Portal = ({ children, modal, theme: themeOverrides }: Props) => { +const Portal = ({ + children, + modal, + onDismiss, + dismissable = true, + theme: themeOverrides, +}: Props) => { const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); const settings = React.useContext(SettingsContext); @@ -56,7 +76,12 @@ const Portal = ({ children, modal, theme: themeOverrides }: Props) => { const reduceMotion = React.useContext(ReduceMotionContext); return ( - + diff --git a/src/components/Portal/PortalConsumer.tsx b/src/components/Portal/PortalConsumer.tsx index e148265104..e04c83acbf 100644 --- a/src/components/Portal/PortalConsumer.tsx +++ b/src/components/Portal/PortalConsumer.tsx @@ -1,24 +1,24 @@ import * as React from 'react'; import type { PortalMethods } from './PortalHost'; +import type { PortalOptions } from './PortalManager'; -type Props = { +type Props = PortalOptions & { manager: PortalMethods; children: React.ReactNode; - modal?: boolean; }; export default class PortalConsumer extends React.Component { componentDidMount() { this.checkManager(); - this.key = this.props.manager.mount(this.props.children, this.props.modal); + this.key = this.props.manager.mount(this.props.children, this.getOptions()); } componentDidUpdate() { this.checkManager(); - this.props.manager.update(this.key, this.props.children, this.props.modal); + this.props.manager.update(this.key, this.props.children, this.getOptions()); } componentWillUnmount() { @@ -29,6 +29,12 @@ export default class PortalConsumer extends React.Component { private key: any; + private getOptions(): PortalOptions { + const { modal, onDismiss, dismissable } = this.props; + + return { modal, onDismiss, dismissable }; + } + private checkManager() { if (!this.props.manager) { throw new Error( diff --git a/src/components/Portal/PortalDismiss.tsx b/src/components/Portal/PortalDismiss.tsx new file mode 100644 index 0000000000..5ee1123e5f --- /dev/null +++ b/src/components/Portal/PortalDismiss.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +import useLatestCallback from 'use-latest-callback'; + +import type { PortalOptions } from './PortalManager'; +import { addEventListener } from '../../utils/addEventListener'; +import { BackHandler } from '../../utils/BackHandler/BackHandler'; + +export type Props = Pick & { + enabled: boolean; +}; + +export default function PortalDismiss({ + enabled, + dismissable, + onDismiss, +}: Props) { + const handleBackPress = useLatestCallback(() => { + if (!enabled) { + return false; + } + + if (dismissable) { + onDismiss?.(); + } + + return true; + }); + + const handleKeyDown = useLatestCallback((event: KeyboardEvent) => { + if ( + !enabled || + event.key !== 'Escape' || + event.defaultPrevented || + event.isComposing + ) { + return; + } + + event.preventDefault(); + + if (dismissable) { + onDismiss?.(); + } + }); + + React.useEffect(() => { + const backSubscription = addEventListener( + BackHandler, + 'hardwareBackPress', + handleBackPress + ); + + const listensForKeys = Platform.OS === 'web' && 'document' in global; + + if (listensForKeys) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => { + backSubscription.remove(); + + if (listensForKeys) { + document.removeEventListener('keydown', handleKeyDown); + } + }; + }, [handleBackPress, handleKeyDown]); + + return null; +} diff --git a/src/components/Portal/PortalHost.tsx b/src/components/Portal/PortalHost.tsx index a095d8d58f..a1762bc099 100644 --- a/src/components/Portal/PortalHost.tsx +++ b/src/components/Portal/PortalHost.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import PortalManager from './PortalManager'; +import type { PortalOptions } from './PortalManager'; export type Props = { children: React.ReactNode; @@ -11,22 +12,22 @@ type Operation = type: 'mount'; key: number; children: React.ReactNode; - modal: boolean | undefined; + options: PortalOptions; } | { type: 'update'; key: number; children: React.ReactNode; - modal: boolean | undefined; + options: PortalOptions; } | { type: 'unmount'; key: number }; export type PortalMethods = { - mount: (children: React.ReactNode, modal: boolean | undefined) => number; + mount: (children: React.ReactNode, options: PortalOptions) => number; update: ( key: number, children: React.ReactNode, - modal: boolean | undefined + options: PortalOptions ) => void; unmount: (key: number) => void; }; @@ -70,10 +71,10 @@ export default class PortalHost extends React.Component { if (action) { switch (action.type) { case 'mount': - manager.mount(action.key, action.children, action.modal); + manager.mount(action.key, action.children, action.options); break; case 'update': - manager.update(action.key, action.children, action.modal); + manager.update(action.key, action.children, action.options); break; case 'unmount': manager.unmount(action.key); @@ -87,13 +88,13 @@ export default class PortalHost extends React.Component { this.manager = manager; }; - private mount = (children: React.ReactNode, modal: boolean | undefined) => { + private mount = (children: React.ReactNode, options: PortalOptions) => { const key = this.nextKey++; if (this.manager) { - this.manager.mount(key, children, modal); + this.manager.mount(key, children, options); } else { - this.queue.push({ type: 'mount', key, children, modal }); + this.queue.push({ type: 'mount', key, children, options }); } return key; @@ -102,12 +103,12 @@ export default class PortalHost extends React.Component { private update = ( key: number, children: React.ReactNode, - modal: boolean | undefined + options: PortalOptions ) => { if (this.manager) { - this.manager.update(key, children, modal); + this.manager.update(key, children, options); } else { - const op: Operation = { type: 'mount', key, children, modal }; + const op: Operation = { type: 'mount', key, children, options }; const index = this.queue.findIndex( (o) => (o.type === 'mount' || o.type === 'update') && o.key === key ); diff --git a/src/components/Portal/PortalManager.tsx b/src/components/Portal/PortalManager.tsx index 307236f17f..3693a5d304 100644 --- a/src/components/Portal/PortalManager.tsx +++ b/src/components/Portal/PortalManager.tsx @@ -1,18 +1,26 @@ import * as React from 'react'; import { StyleSheet } from 'react-native'; +import PortalDismiss from './PortalDismiss'; import PortalLayer from './PortalLayer'; +export type PortalOptions = { + modal: boolean | undefined; + onDismiss: (() => void) | undefined; + dismissable: boolean; +}; + type Props = { children: React.ReactNode; }; type State = { - portals: Array<{ - key: number; - children: React.ReactNode; - modal: boolean | undefined; - }>; + portals: Array< + PortalOptions & { + key: number; + children: React.ReactNode; + } + >; }; /** @@ -23,25 +31,17 @@ export default class PortalManager extends React.Component { portals: [], }; - mount = ( - key: number, - children: React.ReactNode, - modal: boolean | undefined - ) => { + mount = (key: number, children: React.ReactNode, options: PortalOptions) => { this.setState((state) => ({ - portals: [...state.portals, { key, children, modal }], + portals: [...state.portals, { key, children, ...options }], })); }; - update = ( - key: number, - children: React.ReactNode, - modal: boolean | undefined - ) => + update = (key: number, children: React.ReactNode, options: PortalOptions) => this.setState((state) => ({ portals: state.portals.map((item) => { if (item.key === key) { - return { ...item, children, modal }; + return { ...item, children, ...options }; } return item; @@ -70,19 +70,33 @@ export default class PortalManager extends React.Component { > {this.props.children} - {portals.map(({ key, children }, index) => ( - - {children} - - ))} + {portals.map( + ({ key, children, modal, onDismiss, dismissable }, index) => ( + + {modal ? ( + + ) : null} + {children} + + ) + )} ); } diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index 368bd9727f..64dc266946 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -1,10 +1,4 @@ -import { - Text, - StyleSheet, - Platform, - BackHandler as RNBackHandler, -} from 'react-native'; -import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; +import { Text, StyleSheet, Platform } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { act, userEvent } from '@testing-library/react-native'; @@ -14,13 +8,6 @@ import { render, screen } from '../../test-utils'; import Button from '../Button/Button'; import Portal from '../Portal/Portal'; -interface BackHandlerStatic extends RNBackHandlerStatic { - mockPressBack(): void; -} - -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const BackHandler = RNBackHandler as BackHandlerStatic; - afterEach(() => { jest.restoreAllMocks(); }); @@ -87,9 +74,7 @@ describe('Dialog', () => { expect(onDismiss).toHaveBeenCalledTimes(0); }); - it('invokes onDismiss on Android back button press when only dismissableBackButton is true', async () => { - jest.replaceProperty(Platform, 'OS', 'android'); - + it("doesn't invoke onDismiss when the overlay is pressed and dismissableOverlay is false", async () => { const onDismiss = jest.fn(); await render( @@ -97,9 +82,8 @@ describe('Dialog', () => { This is simple dialog @@ -115,13 +99,6 @@ describe('Dialog', () => { }); expect(onDismiss).toHaveBeenCalledTimes(0); - - await act(() => { - BackHandler.mockPressBack(); - jest.runAllTimers(); - }); - - expect(onDismiss).toHaveBeenCalledTimes(1); }); it('applies top margin to the first child', async () => { diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 6a071893c2..6d9be49c47 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -1,7 +1,15 @@ import { BackHandler as RNBackHandler, Text } from 'react-native'; import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; -import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + jest, +} from '@jest/globals'; import { act, fireEvent, userEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; @@ -18,6 +26,7 @@ jest.mock('react-native-safe-area-context', () => ({ interface BackHandlerStatic extends RNBackHandlerStatic { mockPressBack(): void; + exitApp: jest.Mock<() => void>; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -40,6 +49,10 @@ describe('Modal', () => { /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('renders passed children', async () => { await render( @@ -305,7 +318,7 @@ describe('Modal', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('does not invoke onDismiss on Android back button press when dismissableBackButton is false', async () => { + it("doesn't invoke onDismiss when the overlay is pressed and dismissableOverlay is false", async () => { const onDismiss = jest.fn(); await render( @@ -315,27 +328,22 @@ describe('Modal', () => { overlayTestID="backdrop" visible onDismiss={onDismiss} - dismissableBackButton={false} + dismissableOverlay={false} > {null} ); - await act(() => { - BackHandler.mockPressBack(); - jest.runAllTimers(); - }); - - expect(onDismiss).not.toHaveBeenCalled(); - - await userEvent.press(screen.getByRole('button', { name: 'Close modal' })); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); }); - expect(onDismiss).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); }); it('does not render the visually hidden dismiss button for a non-dismissible modal', async () => { @@ -511,6 +519,22 @@ describe('Modal', () => { expect(onDismiss).not.toHaveBeenCalled(); }); + it("doesn't pass the Android back button press on from a non-dismissible modal", async () => { + await render( + + + {null} + + + ); + + await act(() => { + BackHandler.mockPressBack(); + }); + + expect(BackHandler.exitApp).not.toHaveBeenCalled(); + }); + it('runs the fade-in animation when visible changes from false to true', async () => { const { rerender, toJSON } = await render( diff --git a/src/components/__tests__/Portal.test.tsx b/src/components/__tests__/Portal.test.tsx index 64ff1121bb..2b9549eeb6 100644 --- a/src/components/__tests__/Portal.test.tsx +++ b/src/components/__tests__/Portal.test.tsx @@ -1,6 +1,18 @@ -import { Text } from 'react-native'; +import * as React from 'react'; +import { BackHandler as RNBackHandler, Platform, Text } from 'react-native'; +import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; -import { expect, it, jest } from '@jest/globals'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; import { LocaleProvider, useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -15,6 +27,14 @@ import Portal from '../Portal/Portal'; jest.useRealTimers(); +interface BackHandlerStatic extends RNBackHandlerStatic { + mockPressBack(): void; + exitApp: jest.Mock<() => void>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const BackHandler = RNBackHandler as BackHandlerStatic; + it('renders portal with siblings', async () => { const { toJSON } = await render( @@ -232,3 +252,465 @@ it('makes the app content reachable again once the modal unmounts', async () => expect(screen.queryByText('modal content')).not.toBeOnTheScreen(); expect(screen.getByText('page content')).toBeVisible(); }); + +describe('onDismiss', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when the back button is pressed', () => { + const pressBack = async () => { + await act(() => { + BackHandler.mockPressBack(); + }); + }; + + it('closes only the topmost modal portal, then the one underneath on the next press', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + const Portals = ({ upperOpen }: { upperOpen: boolean }) => ( + + + lower + + {upperOpen ? ( + + upper + + ) : null} + + ); + + const { rerender } = await render(); + + await pressBack(); + + expect(onDismissUpper).toHaveBeenCalledTimes(1); + expect(onDismissLower).not.toHaveBeenCalled(); + + await rerender(); + + await pressBack(); + + expect(onDismissLower).toHaveBeenCalledTimes(1); + expect(onDismissUpper).toHaveBeenCalledTimes(1); + }); + + it('lets content inside a modal handle the press first, also after a modal above it closes', async () => { + const onBackInside = jest.fn(() => true); + const onDismissLower = jest.fn(); + + const BackListener = () => { + React.useEffect(() => { + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + onBackInside + ); + + return () => subscription.remove(); + }, []); + + return search; + }; + + const Portals = ({ upperOpen }: { upperOpen: boolean }) => ( + + + + + {upperOpen ? ( + {}}> + upper + + ) : null} + + ); + + const { rerender } = await render(); + + await pressBack(); + + expect(onBackInside).toHaveBeenCalledTimes(1); + + await rerender(); + await rerender(); + + await pressBack(); + + expect(onBackInside).toHaveBeenCalledTimes(2); + expect(onDismissLower).not.toHaveBeenCalled(); + }); + + it('picks the modal drawn on top, not the one that became modal last', async () => { + const onDismissFirst = jest.fn(); + const onDismissSecond = jest.fn(); + + const Portals = ({ firstOpen }: { firstOpen: boolean }) => ( + + + first + + + second + + + ); + + const { rerender } = await render(); + + await rerender(); + + await pressBack(); + + // Portals are drawn in the order they mounted, so the second one is on + // top even though the first became modal after it. + expect(onDismissSecond).toHaveBeenCalledTimes(1); + expect(onDismissFirst).not.toHaveBeenCalled(); + }); + + it('ignores portals above the modal that are not modal', async () => { + const onDismissModal = jest.fn(); + const onDismissPortal = jest.fn(); + + await render( + + + dialog + + + tooltip + + + ); + + await pressBack(); + + expect(onDismissModal).toHaveBeenCalledTimes(1); + expect(onDismissPortal).not.toHaveBeenCalled(); + }); + + it('gives the press to the modal underneath once the one above stops being modal', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + const Portals = ({ upperModal }: { upperModal: boolean }) => ( + + + lower + + + upper + + + ); + + const { rerender } = await render(); + + // E.g. a modal that is still fading out. + await rerender(); + + await pressBack(); + + expect(onDismissLower).toHaveBeenCalledTimes(1); + expect(onDismissUpper).not.toHaveBeenCalled(); + }); + + it('leaves the press alone when no modal is open', async () => { + const onDismiss = jest.fn(); + + await render( + + + tooltip + + + ); + + await pressBack(); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).toHaveBeenCalledTimes(1); + }); + + it("doesn't pass the press on from a modal without onDismiss", async () => { + const onDismissLower = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + await pressBack(); + + expect(onDismissLower).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).not.toHaveBeenCalled(); + }); + + it('stops listening once the host unmounts', async () => { + const onDismiss = jest.fn(); + + const { unmount } = await render( + + + dialog + + + ); + + await unmount(); + + await pressBack(); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the Escape key is pressed on the web', () => { + type FakeKeyboardEvent = ReturnType; + type Listener = (event: FakeKeyboardEvent) => void; + + let platform: { restore(): void }; + let listeners = new Set(); + + beforeAll(() => { + platform = jest.replaceProperty(Platform, 'OS', 'web'); + + // There is no DOM under the React Native preset, and the portal host + // only reaches for one on the web, so the test supplies what it touches. + Object.defineProperty(global, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: Listener) => { + if (type === 'keydown') { + listeners.add(listener); + } + }, + removeEventListener: (type: string, listener: Listener) => { + if (type === 'keydown') { + listeners.delete(listener); + } + }, + }, + }); + }); + + afterAll(() => { + platform.restore(); + delete (global as { document?: unknown }).document; + }); + + beforeEach(() => { + listeners = new Set(); + }); + + const createEvent = ({ + isComposing = false, + }: { + isComposing?: boolean; + }) => { + const event = { + key: 'Escape', + isComposing, + defaultPrevented: false, + preventDefault: jest.fn(() => { + event.defaultPrevented = true; + }), + }; + + return event; + }; + + // Runs the key press the way a browser does: first the focused element + // inside the modal, then the listeners on `document` as it bubbles up. + const pressEscape = async ({ + onFocusedElementKeyDown, + ...options + }: { + onFocusedElementKeyDown?: (event: FakeKeyboardEvent) => void; + isComposing?: boolean; + } = {}) => { + const event = createEvent(options); + + await act(() => { + onFocusedElementKeyDown?.(event); + + for (const listener of listeners) { + listener(event); + } + }); + + return event; + }; + + it('closes only the topmost modal portal', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + const event = await pressEscape(); + + expect(onDismissUpper).toHaveBeenCalledTimes(1); + expect(onDismissLower).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('marks the key handled for a modal without onDismiss', async () => { + const onDismissLower = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + const event = await pressEscape(); + + expect(onDismissLower).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('stays open when an element inside the modal marks the key handled', async () => { + const onDismiss = jest.fn(); + const onFocusedElementKeyDown = jest.fn((event: FakeKeyboardEvent) => + event.preventDefault() + ); + + await render( + + + dialog + + + ); + + await pressEscape({ onFocusedElementKeyDown }); + + expect(onFocusedElementKeyDown).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('stays open while the user is composing text', async () => { + const onDismiss = jest.fn(); + + await render( + + + dialog + + + ); + + const event = await pressEscape({ isComposing: true }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('closes a Modal on Escape following dismissable, not dismissableOverlay', async () => { + const onDismissBlocked = jest.fn(); + const onDismissAllowed = jest.fn(); + + const { rerender } = await render( + + + blocked + + + ); + + await pressEscape(); + + expect(onDismissBlocked).not.toHaveBeenCalled(); + + await rerender( + + + allowed + + + ); + + await pressEscape(); + + expect(onDismissAllowed).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the VoiceOver escape gesture is made', () => { + const escapeFrom = async (text: string) => { + await fireEvent( + screen.getByText(text, { includeHiddenElements: true }), + 'accessibilityEscape' + ); + }; + + it('closes only the topmost modal portal', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + await escapeFrom('lower'); + + expect(onDismissLower).not.toHaveBeenCalled(); + expect(onDismissUpper).not.toHaveBeenCalled(); + + await escapeFrom('upper'); + + expect(onDismissUpper).toHaveBeenCalledTimes(1); + expect(onDismissLower).not.toHaveBeenCalled(); + }); + + it("doesn't close a portal that is not modal", async () => { + const onDismiss = jest.fn(); + + await render( + + + popup + + + ); + + await escapeFrom('popup'); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index 8cf23e1346..2625c341c2 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -212,7 +212,6 @@ exports[`Modal closes a non-dismissible modal when visible changes from true to exports[`Modal closes a non-dismissible modal when visible changes from true to false 2`] = ` <>