Skip to content

Commit 4ce304d

Browse files
author
Konstantin Marushchak
committed
feat(modal): dismiss only the topmost overlay on back or escape
1 parent 9b4baf9 commit 4ce304d

4 files changed

Lines changed: 469 additions & 24 deletions

File tree

src/components/Modal.tsx

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ import type { Props as SurfaceProps, SurfaceStyle } from './Surface';
1515
import { useInternalTheme } from '../core/theming';
1616
import { tokens } from '../theme/tokens';
1717
import type { Elevation, ThemeProp } from '../theme/types';
18-
import { addEventListener } from '../utils/addEventListener';
19-
import { BackHandler } from '../utils/BackHandler/BackHandler';
18+
import { useOverlayDismiss } from '../utils/useOverlayDismiss';
2019

2120
const scrimAlpha = tokens.md.sys.scrim.alpha;
2221

@@ -175,27 +174,11 @@ function Modal({
175174
return () => clearTimeout(timeout);
176175
}, [scale, visible, visibleInternal]);
177176

178-
React.useEffect(() => {
179-
if (!visible) {
180-
return undefined;
181-
}
182-
183-
const onHardwareBackPress = () => {
184-
if (dismissable || dismissableBackButton) {
185-
onDismissCallback();
186-
}
187-
188-
return true;
189-
};
190-
191-
const subscription = addEventListener(
192-
BackHandler,
193-
'hardwareBackPress',
194-
onHardwareBackPress
195-
);
196-
197-
return () => subscription.remove();
198-
}, [dismissable, dismissableBackButton, onDismissCallback, visible]);
177+
useOverlayDismiss({
178+
enabled: visible,
179+
dismissable: dismissableBackButton,
180+
onDismiss: onDismissCallback,
181+
});
199182

200183
const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1);
201184

src/components/__tests__/Modal.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { BackHandler as RNBackHandler, Text } from 'react-native';
22
import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native';
33

4-
import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals';
4+
import {
5+
afterAll,
6+
beforeAll,
7+
beforeEach,
8+
describe,
9+
expect,
10+
it,
11+
jest,
12+
} from '@jest/globals';
513
import { act, userEvent } from '@testing-library/react-native';
614

715
import { render, screen } from '../../test-utils';
@@ -18,6 +26,7 @@ jest.mock('react-native-safe-area-context', () => ({
1826

1927
interface BackHandlerStatic extends RNBackHandlerStatic {
2028
mockPressBack(): void;
29+
exitApp: jest.Mock<() => void>;
2130
}
2231

2332
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -40,6 +49,12 @@ describe('Modal', () => {
4049
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
4150
});
4251

52+
// `exitApp` is one module-level `jest.fn` shared by every test in the file,
53+
// and nothing clears it globally.
54+
beforeEach(() => {
55+
BackHandler.exitApp.mockClear();
56+
});
57+
4358
describe('by default', () => {
4459
it('should render passed children', async () => {
4560
await render(
@@ -331,6 +346,27 @@ describe('Modal', () => {
331346

332347
expect(onDismiss).not.toHaveBeenCalled();
333348
});
349+
350+
it('should not let the press leave the screen behind it', async () => {
351+
await render(
352+
<Portal.Host>
353+
<Modal
354+
testID="modal"
355+
visible
356+
onDismiss={() => {}}
357+
dismissable={false}
358+
>
359+
{null}
360+
</Modal>
361+
</Portal.Host>
362+
);
363+
364+
await act(() => {
365+
BackHandler.mockPressBack();
366+
});
367+
368+
expect(BackHandler.exitApp).not.toHaveBeenCalled();
369+
});
334370
});
335371
});
336372

0 commit comments

Comments
 (0)