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' && (
-
- Dismissable back button
-
- )}
- {Platform.OS === 'android' && (
-
- Undismissable back button
-
- )}
+
+ Undismissable overlay
+
{
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!
-
-
-
-
- Disagree
-
- Agree
-
-
-);
-
-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`] = `
<>