diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 60aaf5c924..3a16c25aee 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -236,6 +236,22 @@ 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.
+The Escape key on web now dismisses the modal too, and `dismissableBackButton` controls it together with the back button. When several modals are open, back and Escape dismiss only the topmost one. When `dismissableBackButton` is `false`, they are absorbed and nothing is dismissed, rather than passed on to the content behind the modal.
+
+Opening a modal now moves focus into it, and closing it returns focus to the element that had it before. You can choose the elements with the new `initialFocusRef` and `restoreFocusRef` props:
+
+```jsx
+
+
+
+```
+
### Dialog
`Dialog` now uses a `Portal` internally and doesn't require an explicit `Portal` wrapper. So you need to remove any existing `Portal` wrappers around `Dialog`:
@@ -261,6 +277,8 @@ The dialog now has the `dialog` role. On web, the dialog's accessible name is se
```
+`Dialog` follows the same back button, Escape key and focus behavior as `Modal`, including the `initialFocusRef` and `restoreFocusRef` props.
+
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.
- The default elevation changed from level `1` to level `3`.
@@ -300,6 +318,8 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`:
```
+On web, a menu now closes on the Escape key when it is pressed down, instead of when it is released. Back and Escape dismiss only the topmost overlay, so a menu opened from a `Modal` or `Dialog` closes first and leaves the modal open.
+
### Searchbar
The misspelled `traileringIcon` props have been renamed:
diff --git a/jest/reanimatedSnapshotSerializer.js b/jest/reanimatedSnapshotSerializer.js
index 0d20dec218..b5ded7eaa3 100644
--- a/jest/reanimatedSnapshotSerializer.js
+++ b/jest/reanimatedSnapshotSerializer.js
@@ -2,6 +2,9 @@ const reanimatedTestProps = new Set([
'jestAnimatedProps',
'jestAnimatedStyle',
'jestInlineStyle',
+ // Reanimated passes the ref it was given down as a prop of its own. Printing
+ // it walks the whole host instance, which never ends.
+ 'forwardedRef',
]);
module.exports = {
diff --git a/jest/testSetup.js b/jest/testSetup.js
index 1033126c45..c205a059e6 100644
--- a/jest/testSetup.js
+++ b/jest/testSetup.js
@@ -87,6 +87,8 @@ jest.mock('react-native', () => {
RN.Animated.loop = loop;
RN.Animated.parallel = parallel;
+ RN.AccessibilityInfo.sendAccessibilityEvent = jest.fn();
+
return RN;
});
diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx
index 218353d5e8..ec9929cb69 100644
--- a/src/components/Dialog/Dialog.tsx
+++ b/src/components/Dialog/Dialog.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Platform, StyleSheet } from 'react-native';
-import type { StyleProp } from 'react-native';
+import type { StyleProp, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -40,6 +40,14 @@ export type Props = {
* Determines Whether the dialog is visible.
*/
visible: boolean;
+ /**
+ * An accessibility element to focus when the dialog opens.
+ */
+ initialFocusRef?: React.RefObject | null>;
+ /**
+ * An accessibility element to focus when the dialog closes.
+ */
+ restoreFocusRef?: React.RefObject | null>;
/**
* Content of the `Dialog`.
*/
@@ -106,6 +114,8 @@ const Dialog = ({
dismissAccessibilityLabel,
'aria-label': ariaLabel,
visible = false,
+ initialFocusRef,
+ restoreFocusRef,
style,
theme: themeOverrides,
testID,
@@ -129,6 +139,8 @@ const Dialog = ({
onDismiss={onDismiss}
dismissAccessibilityLabel={dismissAccessibilityLabel}
visible={visible}
+ initialFocusRef={initialFocusRef}
+ restoreFocusRef={restoreFocusRef}
contentBackgroundColor={backgroundColor}
contentBorderRadius={borderRadius}
contentElevation={DIALOG_ELEVATION}
diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx
index bb28f45422..5c5990a2b8 100644
--- a/src/components/Menu/Menu.tsx
+++ b/src/components/Menu/Menu.tsx
@@ -34,7 +34,7 @@ import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
import type { Elevation, ThemeProp } from '../../theme/types';
import { addEventListener } from '../../utils/addEventListener';
-import { BackHandler } from '../../utils/BackHandler/BackHandler';
+import { useOverlayDismiss } from '../../utils/useOverlayDismiss';
import Portal from '../Portal/Portal';
import Surface from '../Surface';
import type { SurfaceStyle } from '../Surface';
@@ -251,9 +251,6 @@ const Menu = ({
EmitterSubscription | undefined
> = React.useRef(undefined);
- const backHandlerSubscriptionRef: React.RefObject<
- NativeEventSubscription | undefined
- > = React.useRef(undefined);
const dimensionsSubscriptionRef: React.RefObject<
NativeEventSubscription | undefined
> = React.useRef(undefined);
@@ -264,37 +261,26 @@ const Menu = ({
}
}, [onDismiss, visible]);
- const handleKeypress = React.useCallback(
- (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- onDismiss?.();
- }
- },
- [onDismiss]
- );
+ // Back and Escape go through the overlay stack, so that a menu opened from a
+ // modal closes first and the modal stays. Without `onDismiss` there is
+ // nothing to close, and the press is left to whatever is underneath.
+ useOverlayDismiss({
+ enabled: visible && onDismiss !== undefined,
+ dismissable: true,
+ onDismiss,
+ });
const removeListeners = React.useCallback(() => {
- backHandlerSubscriptionRef.current?.remove();
dimensionsSubscriptionRef.current?.remove();
-
- if (Platform.OS === 'web' && 'document' in global) {
- document.removeEventListener('keyup', handleKeypress);
- }
- }, [handleKeypress]);
+ }, []);
const attachListeners = React.useCallback(() => {
- backHandlerSubscriptionRef.current = addEventListener(
- BackHandler,
- 'hardwareBackPress',
- handleDismiss
- );
dimensionsSubscriptionRef.current = addEventListener(
Dimensions,
'change',
handleDismiss
);
- Platform.OS === 'web' && document.addEventListener('keyup', handleKeypress);
- }, [handleDismiss, handleKeypress]);
+ }, [handleDismiss]);
const measureMenuLayout = () =>
new Promise((resolve) => {
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index 9e1ab8a821..55b834b224 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -15,22 +15,28 @@ 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';
+import { useOverlayDismiss } from '../utils/useOverlayDismiss';
+import { useOverlayFocus } from '../utils/useOverlayFocus';
const scrimAlpha = tokens.md.sys.scrim.alpha;
export type Props = {
/**
- * Determines whether clicking outside the modal dismisses it.
+ * Determines whether clicking outside the modal dismisses it. It also decides
+ * whether the screen reader escape gesture and the visually hidden dismiss
+ * button dismiss it, and is the default for `dismissableBackButton`.
*/
dismissable?: boolean;
/**
- * Determines whether clicking Android hardware back button dismisses the dialog.
+ * Determines whether the Android hardware back button and the Escape key on the web dismiss the modal.
+ * While a modal is visible, only the topmost one handles these. When this is `false`,
+ * they are absorbed and nothing is dismissed, rather than passed on to what is behind the modal.
*/
dismissableBackButton?: boolean;
/**
* Callback that is called when the user dismisses the modal.
+ * A visible modal always takes the back button and Escape key, so without
+ * this callback they do nothing, and on Android the user cannot go back.
*/
onDismiss?: () => void;
/**
@@ -54,6 +60,14 @@ export type Props = {
* Determines Whether the modal is visible.
*/
visible: boolean;
+ /**
+ * Element to focus when the modal opens.
+ */
+ initialFocusRef?: React.RefObject | null>;
+ /**
+ * Element to focus when the modal closes.
+ */
+ restoreFocusRef?: React.RefObject | null>;
/**
* Content of the `Modal`.
*/
@@ -94,6 +108,10 @@ export type Props = {
};
const DEFAULT_DURATION = 220;
+
+// Having the handler at all keeps the screen reader escape gesture on this
+// modal, instead of passing it on to the modal behind it.
+const absorbAccessibilityEscape = () => {};
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
/**
@@ -138,6 +156,8 @@ function Modal({
dismissable = true,
dismissableBackButton = dismissable,
visible = false,
+ initialFocusRef,
+ restoreFocusRef,
dismissAccessibilityLabel = 'Close modal',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
@@ -167,6 +187,15 @@ function Modal({
const { scale } = theme.animation;
+ const contentRef = React.useRef>(null);
+
+ const { focusInitialTarget, restoreFocus } = useOverlayFocus({
+ visible,
+ containerRef: contentRef,
+ initialFocusRef,
+ restoreFocusRef,
+ });
+
React.useEffect(() => {
const timeout = setTimeout(() => setAnimatedVisible(visible), 0);
@@ -187,26 +216,28 @@ function Modal({
}, [scale, visible, visibleInternal]);
React.useEffect(() => {
- if (!visible) {
- return undefined;
+ if (!visibleInternal) {
+ restoreFocus();
}
+ }, [restoreFocus, visibleInternal]);
- const onHardwareBackPress = () => {
- if (dismissableBackButton) {
- onDismissCallback();
- }
+ useOverlayDismiss({
+ enabled: visible,
+ dismissable: dismissableBackButton,
+ onDismiss: onDismissCallback,
+ });
- return true;
- };
+ React.useEffect(() => {
+ if (!animatedVisible) {
+ return undefined;
+ }
- const subscription = addEventListener(
- BackHandler,
- 'hardwareBackPress',
- onHardwareBackPress
- );
+ // Transparent views are not in the accessibility tree, so focus has to
+ // wait for the fade to finish.
+ const timeout = setTimeout(focusInitialTarget, scale * DEFAULT_DURATION);
- return () => subscription.remove();
- }, [dismissableBackButton, onDismissCallback, visible]);
+ return () => clearTimeout(timeout);
+ }, [animatedVisible, focusInitialTarget, scale]);
const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1);
@@ -240,7 +271,9 @@ function Modal({
pointerEvents={visible ? 'auto' : 'none'}
aria-live="polite"
style={StyleSheet.absoluteFill}
- onAccessibilityEscape={dismissable ? onDismissCallback : undefined}
+ onAccessibilityEscape={
+ dismissable ? onDismissCallback : absorbAccessibilityEscape
+ }
testID={testID}
>
void>;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const BackHandler = RNBackHandler as BackHandlerStatic;
+
+const MenuInDialog = ({
+ menuVisible = true,
+ onDismissDialog,
+ onDismissMenu,
+}: {
+ menuVisible?: boolean;
+ onDismissDialog: () => void;
+ onDismissMenu: () => void;
+}) => (
+
+
+
+);
+
+describe('a menu inside a dialog', () => {
+ beforeEach(() => {
+ BackHandler.exitApp.mockClear();
+ });
+
+ describe('when the back button is pressed', () => {
+ it('closes only the menu', async () => {
+ const onDismissDialog = jest.fn();
+ const onDismissMenu = jest.fn();
+
+ await render(
+
+ );
+ await screen.findByText('Item');
+
+ await act(() => {
+ BackHandler.mockPressBack();
+ });
+
+ expect(onDismissMenu).toHaveBeenCalledTimes(1);
+ expect(onDismissDialog).not.toHaveBeenCalled();
+ });
+
+ it('closes the dialog once the menu has closed', async () => {
+ const onDismissDialog = jest.fn();
+ const onDismissMenu = jest.fn();
+
+ const { rerender } = await render(
+
+ );
+ await screen.findByText('Item');
+
+ await rerender(
+
+ );
+
+ await act(() => {
+ BackHandler.mockPressBack();
+ });
+
+ expect(onDismissDialog).toHaveBeenCalledTimes(1);
+ expect(onDismissMenu).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the Escape key is pressed on the web', () => {
+ let platform: { restore(): void };
+ let keyDownListeners = new Set<(event: unknown) => void>();
+
+ beforeAll(() => {
+ platform = jest.replaceProperty(Platform, 'OS', 'web');
+
+ // There is no DOM under the React Native preset, and the overlays only
+ // reach for one on the web, so the test supplies what they touch.
+ Object.defineProperty(global, 'HTMLElement', {
+ configurable: true,
+ value: class {},
+ });
+ Object.defineProperty(global, 'document', {
+ configurable: true,
+ value: {
+ activeElement: null,
+ addEventListener: (
+ type: string,
+ listener: (event: unknown) => void,
+ capture?: boolean
+ ) => {
+ if (type === 'keydown' && capture === true) {
+ keyDownListeners.add(listener);
+ }
+ },
+ removeEventListener: (
+ _type: string,
+ listener: (event: unknown) => void
+ ) => {
+ keyDownListeners.delete(listener);
+ },
+ },
+ });
+ });
+
+ afterAll(() => {
+ platform.restore();
+ delete (global as { document?: unknown }).document;
+ delete (global as { HTMLElement?: unknown }).HTMLElement;
+ });
+
+ beforeEach(() => {
+ keyDownListeners = new Set();
+ });
+
+ const pressEscape = async () => {
+ const event = {
+ key: 'Escape',
+ defaultPrevented: false,
+ preventDefault: jest.fn(),
+ stopImmediatePropagation: jest.fn(),
+ };
+
+ await act(() => {
+ keyDownListeners.forEach((listener) => listener(event));
+ });
+ };
+
+ it('closes only the menu', async () => {
+ const onDismissDialog = jest.fn();
+ const onDismissMenu = jest.fn();
+
+ await render(
+
+ );
+ await screen.findByText('Item');
+
+ await pressEscape();
+
+ expect(onDismissMenu).toHaveBeenCalledTimes(1);
+ expect(onDismissDialog).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx
index 0083a28f84..60d3c6c6c1 100644
--- a/src/components/__tests__/Modal.test.tsx
+++ b/src/components/__tests__/Modal.test.tsx
@@ -1,6 +1,20 @@
-import { BackHandler as RNBackHandler, Text } from 'react-native';
-
-import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals';
+import * as React from 'react';
+import {
+ AccessibilityInfo,
+ BackHandler as RNBackHandler,
+ Text,
+ View,
+} from 'react-native';
+
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
import { act, fireEvent, userEvent } from '@testing-library/react-native';
import { render, screen } from '../../test-utils';
@@ -17,11 +31,30 @@ jest.mock('react-native-safe-area-context', () => ({
type BackHandlerStatic = typeof RNBackHandler & {
mockPressBack(): void;
+ exitApp: jest.Mock<() => void>;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const BackHandler = RNBackHandler as BackHandlerStatic;
+const sendAccessibilityEvent = jest.mocked(
+ AccessibilityInfo.sendAccessibilityEvent
+);
+
+// The modal queues its fade, and the fade finishing queues the focus move that
+// follows it. Drained in passes so that tests need not count the steps.
+const settle = async () => {
+ for (let pass = 0; pass < 5; pass += 1) {
+ await act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ if (jest.getTimerCount() === 0) {
+ return;
+ }
+ }
+};
+
describe('Modal', () => {
beforeAll(() => {
jest.useFakeTimers();
@@ -39,6 +72,13 @@ describe('Modal', () => {
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
});
+ // `exitApp` is one module-level `jest.fn` shared by every test in the file,
+ // and nothing clears it globally.
+ beforeEach(() => {
+ BackHandler.exitApp.mockClear();
+ sendAccessibilityEvent.mockClear();
+ });
+
it('renders passed children', async () => {
await render(
@@ -419,6 +459,39 @@ describe('Modal', () => {
expect(onDismiss).not.toHaveBeenCalled();
});
+ it('does not pass the accessibility escape gesture of a non-dismissible modal on to the modal behind it', async () => {
+ const onDismissOuter = jest.fn();
+ const onDismissInner = jest.fn();
+
+ await render(
+
+
+
+
+ {null}
+
+
+
+
+ );
+
+ await fireEvent(screen.getByTestId('inner'), 'accessibilityEscape');
+
+ expect(onDismissInner).not.toHaveBeenCalled();
+ expect(onDismissOuter).not.toHaveBeenCalled();
+ });
+
it('keeps a non-dismissible modal on screen on the accessibility escape gesture', async () => {
const onDismiss = jest.fn();
@@ -510,6 +583,104 @@ describe('Modal', () => {
expect(onDismiss).not.toHaveBeenCalled();
});
+ it('does not let the Android back button leave the screen behind a non-dismissible modal', async () => {
+ await render(
+
+ {}} dismissable={false}>
+ {null}
+
+
+ );
+
+ await act(() => {
+ BackHandler.mockPressBack();
+ });
+
+ expect(BackHandler.exitApp).not.toHaveBeenCalled();
+ });
+
+ it('moves the screen reader into the content when it opens', async () => {
+ await render(
+
+
+ Modal content
+
+
+ );
+
+ await settle();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ expect.anything(),
+ 'focus'
+ );
+ });
+
+ it('moves the screen reader to initialFocusRef when it opens', async () => {
+ const initialFocusRef = React.createRef>();
+
+ await render(
+
+
+
+ Modal content
+
+
+ );
+
+ await settle();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ initialFocusRef.current,
+ 'focus'
+ );
+ });
+
+ it('sends the screen reader back to restoreFocusRef when it closes', async () => {
+ const restoreFocusRef = React.createRef>();
+
+ const { rerender } = await render(
+
+
+
+ Modal content
+
+
+ );
+
+ await settle();
+
+ sendAccessibilityEvent.mockClear();
+
+ await rerender(
+
+
+
+ Modal content
+
+
+ );
+ await act(() => {
+ jest.advanceTimersByTime(1);
+ });
+
+ // The screen behind the modal stays inert until the fade is over, so
+ // focus waits with it.
+ expect(sendAccessibilityEvent).not.toHaveBeenCalled();
+ expect(screen.getByTestId('modal')).toBeOnTheScreen();
+
+ await settle();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ restoreFocusRef.current,
+ 'focus'
+ );
+ expect(screen.queryByTestId('modal')).not.toBeOnTheScreen();
+ });
+
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__/__snapshots__/FABExtended.test.tsx.snap b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap
index 644a640c5a..2e737824b9 100644
--- a/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/FABExtended.test.tsx.snap
@@ -284,7 +284,6 @@ exports[`renders extended FAB collapsed 1`] = `
void>;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const BackHandler = RNBackHandler as BackHandlerStatic;
+
+const Probe = ({
+ label,
+ visible,
+ dismissable = true,
+ onDismiss,
+}: {
+ label: string;
+ visible: boolean;
+ dismissable?: boolean;
+ onDismiss?: () => void;
+}) => {
+ useOverlayDismiss({ enabled: visible, dismissable, onDismiss });
+
+ return visible ? {label} : null;
+};
+
+// A `Portal.Host` inside a modal puts the inner modal's content in a host of
+// its own, so the two modals do not share a parent host.
+const NestedModals = ({
+ onDismissOuter,
+ onDismissInner,
+}: {
+ onDismissOuter: () => void;
+ onDismissInner: () => void;
+}) => (
+
+
+ outer
+
+
+ inner
+
+
+
+
+);
+
+const pressBack = async () => {
+ await act(() => {
+ BackHandler.mockPressBack();
+ });
+};
+
+describe('useOverlayDismiss', () => {
+ beforeEach(() => {
+ BackHandler.exitApp.mockClear();
+ });
+
+ describe('when the back button is pressed', () => {
+ it('closes the overlay that opened last', async () => {
+ const onDismissFirst = jest.fn();
+ const onDismissSecond = jest.fn();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+
+ await rerender(
+ <>
+
+
+ >
+ );
+
+ await pressBack();
+
+ expect(onDismissSecond).toHaveBeenCalledTimes(1);
+ expect(onDismissFirst).not.toHaveBeenCalled();
+ });
+
+ it('closes the inner modal of two nested portal hosts', async () => {
+ const onDismissOuter = jest.fn();
+ const onDismissInner = jest.fn();
+
+ await render(
+
+ );
+ await screen.findByText('inner');
+
+ await pressBack();
+
+ expect(onDismissInner).toHaveBeenCalledTimes(1);
+ expect(onDismissOuter).not.toHaveBeenCalled();
+ });
+
+ it('closes one overlay per press instead of all of them at once', async () => {
+ const onDismissFirst = jest.fn();
+ const onDismissSecond = jest.fn();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+
+ await pressBack();
+
+ expect(onDismissSecond).toHaveBeenCalledTimes(1);
+ expect(onDismissFirst).not.toHaveBeenCalled();
+
+ await rerender(
+ <>
+
+
+ >
+ );
+
+ await pressBack();
+
+ expect(onDismissFirst).toHaveBeenCalledTimes(1);
+ expect(onDismissSecond).toHaveBeenCalledTimes(1);
+ });
+
+ it('gives the press back to the overlay underneath when the one above unmounts', async () => {
+ const onDismissFirst = jest.fn();
+ const onDismissSecond = jest.fn();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+
+ await rerender(
+
+ );
+
+ await pressBack();
+
+ expect(onDismissFirst).toHaveBeenCalledTimes(1);
+ expect(onDismissSecond).not.toHaveBeenCalled();
+ });
+
+ it('leaves the press alone for an overlay that is mounted but closed', async () => {
+ const onDismiss = jest.fn();
+
+ await render(
+
+ );
+
+ await pressBack();
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(BackHandler.exitApp).toHaveBeenCalledTimes(1);
+ });
+
+ it('absorbs the press without closing anything when the overlay may not be dismissed', async () => {
+ const onDismissFirst = jest.fn();
+ const onDismissBlocking = jest.fn();
+
+ await render(
+ <>
+
+
+ >
+ );
+
+ await pressBack();
+
+ expect(onDismissBlocking).not.toHaveBeenCalled();
+ // Neither the overlay underneath nor the screen behind it: an overlay
+ // the user may not dismiss still keeps the back button from leaving.
+ expect(onDismissFirst).not.toHaveBeenCalled();
+ expect(BackHandler.exitApp).not.toHaveBeenCalled();
+ });
+
+ it('takes the press from a modal once something else opens over it', async () => {
+ const onDismissModal = jest.fn();
+ const onDismissProbe = jest.fn();
+
+ const Overlays = ({ probeVisible }: { probeVisible: boolean }) => (
+
+
+ modal
+
+
+
+ );
+
+ const { rerender } = await render();
+
+ await act(() => {
+ jest.runAllTimers();
+ });
+
+ await rerender();
+
+ // An open modal hides its siblings from assistive technology, so the
+ // probe is on screen but not reachable by an accessibility query.
+ expect(
+ screen.getByText('probe', { includeHiddenElements: true })
+ ).toBeOnTheScreen();
+
+ await pressBack();
+
+ expect(onDismissProbe).toHaveBeenCalledTimes(1);
+ expect(onDismissModal).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the Escape key is pressed on the web', () => {
+ let platform: { restore(): void };
+ let keyDownListeners = new Set<(event: unknown) => void>();
+
+ beforeAll(() => {
+ platform = jest.replaceProperty(Platform, 'OS', 'web');
+
+ // There is no DOM under the React Native preset, and the hook only
+ // reaches for one on the web, so the test supplies what it touches.
+ // A real `Modal` also reads `HTMLElement` and `document.activeElement` to
+ // manage focus. Nothing here is focusable, so an empty class will do.
+ Object.defineProperty(global, 'HTMLElement', {
+ configurable: true,
+ value: class {},
+ });
+ Object.defineProperty(global, 'document', {
+ configurable: true,
+ value: {
+ activeElement: null,
+ addEventListener: (
+ type: string,
+ listener: (event: unknown) => void,
+ capture?: boolean
+ ) => {
+ if (type === 'keydown' && capture === true) {
+ keyDownListeners.add(listener);
+ }
+ },
+ removeEventListener: (
+ _type: string,
+ listener: (event: unknown) => void
+ ) => {
+ keyDownListeners.delete(listener);
+ },
+ },
+ });
+ });
+
+ afterAll(() => {
+ platform.restore();
+ delete (global as { document?: unknown }).document;
+ delete (global as { HTMLElement?: unknown }).HTMLElement;
+ });
+
+ beforeEach(() => {
+ keyDownListeners = new Set();
+ });
+
+ const pressEscape = async ({ defaultPrevented = false } = {}) => {
+ const event = {
+ key: 'Escape',
+ defaultPrevented,
+ preventDefault: jest.fn(),
+ stopImmediatePropagation: jest.fn(),
+ };
+
+ await act(() => {
+ keyDownListeners.forEach((listener) => listener(event));
+ });
+
+ return event;
+ };
+
+ it('closes the overlay that opened last', async () => {
+ const onDismissFirst = jest.fn();
+ const onDismissSecond = jest.fn();
+
+ await render(
+ <>
+
+
+ >
+ );
+
+ const event = await pressEscape();
+
+ expect(onDismissSecond).toHaveBeenCalledTimes(1);
+ expect(onDismissFirst).not.toHaveBeenCalled();
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ expect(event.stopImmediatePropagation).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes the inner modal of two nested portal hosts', async () => {
+ const onDismissOuter = jest.fn();
+ const onDismissInner = jest.fn();
+
+ await render(
+
+ );
+ await screen.findByText('inner');
+
+ await pressEscape();
+
+ expect(onDismissInner).toHaveBeenCalledTimes(1);
+ expect(onDismissOuter).not.toHaveBeenCalled();
+ });
+
+ it('absorbs the key without closing anything when the overlay may not be dismissed', async () => {
+ const onDismiss = jest.fn();
+
+ await render(
+
+ );
+
+ const event = await pressEscape();
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(event.stopImmediatePropagation).toHaveBeenCalledTimes(1);
+ });
+
+ it('stays out of the way once something nearer the key press handled it', async () => {
+ const onDismiss = jest.fn();
+
+ await render();
+
+ const event = await pressEscape({ defaultPrevented: true });
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/utils/__tests__/useOverlayFocus.test.tsx b/src/utils/__tests__/useOverlayFocus.test.tsx
new file mode 100644
index 0000000000..945ca46f6b
--- /dev/null
+++ b/src/utils/__tests__/useOverlayFocus.test.tsx
@@ -0,0 +1,388 @@
+import * as React from 'react';
+import { AccessibilityInfo, View } from 'react-native';
+
+import { beforeEach, describe, expect, it, jest } from '@jest/globals';
+import { act, fireEvent } from '@testing-library/react-native';
+
+import { render, screen } from '../../test-utils';
+import { useOverlayFocus } from '../useOverlayFocus';
+
+const sendAccessibilityEvent = jest.mocked(
+ AccessibilityInfo.sendAccessibilityEvent
+);
+
+beforeEach(() => {
+ sendAccessibilityEvent.mockClear();
+});
+
+const layoutEvent = {
+ nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 100 } },
+};
+
+type OverlayProps = {
+ visible: boolean;
+ contentRef: React.RefObject | null>;
+ initialFocusRef?: React.RefObject | null>;
+ restoreFocusRef?: React.RefObject | null>;
+};
+
+/**
+ * The content stays mounted whether or not the overlay is open, the way an
+ * overlay that animates itself out does.
+ */
+const Overlay = ({
+ visible,
+ contentRef,
+ initialFocusRef,
+ restoreFocusRef,
+}: OverlayProps) => {
+ const { focusInitialTarget, restoreFocus } = useOverlayFocus({
+ visible,
+ containerRef: contentRef,
+ initialFocusRef,
+ restoreFocusRef,
+ });
+
+ React.useEffect(() => {
+ if (!visible) {
+ restoreFocus();
+ }
+ }, [restoreFocus, visible]);
+
+ return (
+
+ );
+};
+
+const layOutContent = async () => {
+ await fireEvent(screen.getByTestId('content'), 'layout', layoutEvent);
+};
+
+/** Focus is handed back a tick after the overlay closes. */
+const settle = async () => {
+ await act(() => {
+ jest.runOnlyPendingTimers();
+ });
+};
+
+describe('useOverlayFocus', () => {
+ describe('when the overlay opens', () => {
+ it('moves focus to the overlay content', async () => {
+ const contentRef = React.createRef>();
+
+ await render();
+ await layOutContent();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ contentRef.current,
+ 'focus'
+ );
+ });
+
+ it('moves focus to the requested element instead of the content', async () => {
+ const contentRef = React.createRef>();
+ const initialFocusRef =
+ React.createRef>();
+
+ await render(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ initialFocusRef.current,
+ 'focus'
+ );
+ });
+
+ it('moves focus again when the overlay reopens', async () => {
+ const contentRef = React.createRef>();
+ const restoreFocusRef =
+ React.createRef>();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+
+ await rerender(
+ <>
+
+
+ >
+ );
+ await settle();
+ sendAccessibilityEvent.mockClear();
+
+ await rerender(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ contentRef.current,
+ 'focus'
+ );
+ });
+ });
+
+ describe('while the overlay is closed', () => {
+ it('leaves focus alone when the content lays out', async () => {
+ const contentRef = React.createRef>();
+
+ await render();
+ await layOutContent();
+
+ expect(sendAccessibilityEvent).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the overlay closes', () => {
+ it('moves focus back to the requested element', async () => {
+ const contentRef = React.createRef>();
+ const restoreFocusRef =
+ React.createRef>();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+
+ sendAccessibilityEvent.mockClear();
+
+ await rerender(
+ <>
+
+
+ >
+ );
+ await settle();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ restoreFocusRef.current,
+ 'focus'
+ );
+ });
+
+ it('moves focus back only once, however the overlay goes away', async () => {
+ const contentRef = React.createRef>();
+ const restoreFocusRef =
+ React.createRef>();
+
+ const { rerender, unmount } = await render(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+
+ sendAccessibilityEvent.mockClear();
+
+ await rerender(
+ <>
+
+
+ >
+ );
+ await settle();
+ await unmount();
+ await settle();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not restore focus from an interrupted close', async () => {
+ const contentRef = React.createRef>();
+ const restoreFocusRef =
+ React.createRef>();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+ sendAccessibilityEvent.mockClear();
+
+ await rerender(
+ <>
+
+
+ >
+ );
+ await rerender(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+ await settle();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ contentRef.current,
+ 'focus'
+ );
+ });
+
+ it('moves focus back when an open overlay unmounts', async () => {
+ const contentRef = React.createRef>();
+ const restoreFocusRef =
+ React.createRef>();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+ await layOutContent();
+ sendAccessibilityEvent.mockClear();
+
+ await rerender();
+ await settle();
+
+ expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1);
+ expect(sendAccessibilityEvent).toHaveBeenCalledWith(
+ restoreFocusRef.current,
+ 'focus'
+ );
+ });
+
+ it('leaves focus alone when no element was given to restore it to', async () => {
+ const contentRef = React.createRef>();
+
+ const { rerender } = await render(
+
+ );
+ await layOutContent();
+
+ sendAccessibilityEvent.mockClear();
+
+ await rerender();
+ await settle();
+
+ expect(sendAccessibilityEvent).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the overlay never opened', () => {
+ it('leaves focus alone as it goes away', async () => {
+ const contentRef = React.createRef>();
+ const restoreFocusRef =
+ React.createRef>();
+
+ const { unmount } = await render(
+ <>
+
+
+ >
+ );
+ await unmount();
+ await settle();
+
+ expect(sendAccessibilityEvent).not.toHaveBeenCalled();
+ });
+
+ it('leaves focus alone when it opens and closes without ever laying out', async () => {
+ const contentRef = React.createRef>();
+ const restoreFocusRef =
+ React.createRef>();
+
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+
+ await rerender(
+ <>
+
+
+ >
+ );
+ await settle();
+
+ expect(sendAccessibilityEvent).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/utils/useOverlayDismiss.tsx b/src/utils/useOverlayDismiss.tsx
new file mode 100644
index 0000000000..cf84dddb8a
--- /dev/null
+++ b/src/utils/useOverlayDismiss.tsx
@@ -0,0 +1,117 @@
+import * as React from 'react';
+import { Platform } from 'react-native';
+
+import useLatestCallback from 'use-latest-callback';
+
+import { addEventListener } from './addEventListener';
+import { BackHandler } from './BackHandler/BackHandler';
+
+const visibleOverlays: Array = [];
+
+let nextKey = 0;
+
+const registerOverlay = () => {
+ const key = nextKey++;
+
+ visibleOverlays.push(key);
+
+ return key;
+};
+
+const unregisterOverlay = (key: number) => {
+ const index = visibleOverlays.indexOf(key);
+
+ if (index === -1) {
+ return;
+ }
+
+ visibleOverlays.splice(index, 1);
+};
+
+const isTopmost = (key: number | undefined) =>
+ key !== undefined && visibleOverlays[visibleOverlays.length - 1] === key;
+
+export type OverlayDismissOptions = {
+ enabled: boolean;
+ /** When false the press is absorbed, not passed on; same for Escape. */
+ dismissable: boolean;
+ /** Called when the overlay should close. */
+ onDismiss?: () => void;
+};
+
+/**
+ * Closes only the overlay on top when the user presses back or Escape.
+ */
+export function useOverlayDismiss({
+ enabled,
+ dismissable,
+ onDismiss,
+}: OverlayDismissOptions) {
+ const overlayKey = React.useRef(undefined);
+
+ React.useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ const registeredKey = registerOverlay();
+
+ overlayKey.current = registeredKey;
+
+ return () => {
+ unregisterOverlay(registeredKey);
+
+ overlayKey.current = undefined;
+ };
+ }, [enabled]);
+
+ const handleDismiss = useLatestCallback(() => {
+ if (!isTopmost(overlayKey.current)) {
+ return false;
+ }
+
+ if (dismissable) {
+ onDismiss?.();
+ }
+
+ return true;
+ });
+
+ React.useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ const subscription = addEventListener(
+ BackHandler,
+ 'hardwareBackPress',
+ handleDismiss
+ );
+
+ return () => subscription.remove();
+ }, [enabled, handleDismiss]);
+
+ React.useEffect(() => {
+ if (!enabled || Platform.OS !== 'web' || !('document' in global)) {
+ return undefined;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Something nearer the key press already dealt with it.
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return;
+ }
+
+ if (!handleDismiss()) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ };
+
+ document.addEventListener('keydown', handleKeyDown, true);
+
+ return () => document.removeEventListener('keydown', handleKeyDown, true);
+ }, [enabled, handleDismiss]);
+}
diff --git a/src/utils/useOverlayFocus.tsx b/src/utils/useOverlayFocus.tsx
new file mode 100644
index 0000000000..93be18a0f4
--- /dev/null
+++ b/src/utils/useOverlayFocus.tsx
@@ -0,0 +1,155 @@
+import * as React from 'react';
+import { AccessibilityInfo, Platform } from 'react-native';
+import type { View } from 'react-native';
+
+import useLatestCallback from 'use-latest-callback';
+
+const TABBABLE_SELECTOR = [
+ 'a[href]',
+ 'button:not(:disabled)',
+ 'input:not(:disabled)',
+ 'select:not(:disabled)',
+ 'textarea:not(:disabled)',
+ '[tabindex]',
+]
+ .map((selector) => `${selector}:not([tabindex="-1"])`)
+ .join(', ');
+
+const isVisible = (element: HTMLElement) =>
+ element.getClientRects().length > 0 &&
+ getComputedStyle(element).visibility !== 'hidden';
+
+const findFirstTabbable = (container: HTMLElement) =>
+ Array.from(container.querySelectorAll(TABBABLE_SELECTOR)).find(
+ isVisible
+ ) ?? null;
+
+// Prefer `target`. Search inside it only if the browser refused focus.
+const moveWebFocusTo = (
+ target: React.ComponentRef | HTMLElement | null
+) => {
+ // Anything outside the document has nowhere for focus to go.
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+
+ target.focus();
+
+ // The browser refused focus, so try the first element inside instead.
+ if (document.activeElement !== target) {
+ findFirstTabbable(target)?.focus();
+ }
+
+ return true;
+};
+
+const moveNativeFocusTo = (target: React.ComponentRef | null) => {
+ if (!target) {
+ return false;
+ }
+
+ AccessibilityInfo.sendAccessibilityEvent(target, 'focus');
+
+ return true;
+};
+
+export type OverlayFocusOptions = {
+ /** Whether the overlay is open. Drives each capture and restore cycle. */
+ visible: boolean;
+ /** The overlay's own content. Focused when no `initialFocusRef` is provided. */
+ containerRef: React.RefObject | null>;
+ /** Element to focus when the overlay opens, instead of the container. */
+ initialFocusRef?: React.RefObject | null>;
+ /** Element to focus when the overlay closes. */
+ restoreFocusRef?: React.RefObject | null>;
+};
+
+/**
+ * Moves focus into an overlay when it opens and back when it closes.
+ *
+ * @returns
+ *
+ * `focusInitialTarget`, to call once the overlay can take focus.
+ * `restoreFocus`, to call once it can no longer.
+ */
+export function useOverlayFocus({
+ visible,
+ containerRef,
+ initialFocusRef,
+ restoreFocusRef,
+}: OverlayFocusOptions) {
+ const hasMovedInitialFocus = React.useRef(false);
+ const hasRestoredFocus = React.useRef(false);
+ const focusedWebElementBeforeOpen = React.useRef(null);
+ const restoreTimeout = React.useRef>(undefined);
+
+ const focusInitialTarget = useLatestCallback(() => {
+ if (!visible || hasMovedInitialFocus.current) {
+ return;
+ }
+
+ const target = initialFocusRef?.current ?? containerRef.current;
+
+ hasMovedInitialFocus.current =
+ Platform.OS === 'web'
+ ? moveWebFocusTo(target)
+ : moveNativeFocusTo(target);
+ });
+
+ const restoreFocus = useLatestCallback(() => {
+ // An overlay that never took focus has none to give back.
+ if (!hasMovedInitialFocus.current || hasRestoredFocus.current) {
+ return;
+ }
+
+ hasRestoredFocus.current = true;
+
+ // Wait for the close render to remove background inertness.
+ restoreTimeout.current = setTimeout(() => {
+ const elementBeforeOpen = focusedWebElementBeforeOpen.current;
+
+ focusedWebElementBeforeOpen.current = null;
+
+ if (Platform.OS === 'web') {
+ moveWebFocusTo(
+ restoreFocusRef ? restoreFocusRef.current : elementBeforeOpen
+ );
+
+ return;
+ }
+
+ moveNativeFocusTo(restoreFocusRef?.current ?? null);
+ }, 0);
+ });
+
+ React.useEffect(() => {
+ if (!visible) {
+ return;
+ }
+
+ clearTimeout(restoreTimeout.current);
+ restoreTimeout.current = undefined;
+ hasMovedInitialFocus.current = false;
+ hasRestoredFocus.current = false;
+
+ // Captured on open, not on mount: overlays mount long before they show.
+ if (Platform.OS === 'web' && 'document' in global) {
+ const activeElement = document.activeElement;
+ const container = containerRef.current;
+ const focusStayedInOverlay =
+ container instanceof HTMLElement &&
+ activeElement instanceof HTMLElement &&
+ container.contains(activeElement);
+
+ // Focus still inside -> reopen mid-close -> keep the first invoker.
+ if (!focusStayedInOverlay) {
+ focusedWebElementBeforeOpen.current =
+ activeElement instanceof HTMLElement ? activeElement : null;
+ }
+ }
+ }, [containerRef, visible]);
+
+ React.useEffect(() => () => restoreFocus(), [restoreFocus]);
+
+ return { focusInitialTarget, restoreFocus };
+}