From 0aa22c56b6ea8e096cd54a4f5b84279a8ab2cfb8 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 17 Sep 2026 00:12:01 +0200 Subject: [PATCH 1/4] feat(modal): dismiss only the topmost overlay on back or escape --- src/components/Modal.tsx | 29 +- src/components/__tests__/Modal.test.tsx | 33 +- .../__tests__/useOverlayDismiss.test.tsx | 308 ++++++++++++++++++ src/utils/useOverlayDismiss.tsx | 117 +++++++ 4 files changed, 463 insertions(+), 24 deletions(-) create mode 100644 src/utils/__tests__/useOverlayDismiss.test.tsx create mode 100644 src/utils/useOverlayDismiss.tsx diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 9e1ab8a821..d9c6599443 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -15,8 +15,7 @@ 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'; const scrimAlpha = tokens.md.sys.scrim.alpha; @@ -186,27 +185,11 @@ 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]); + useOverlayDismiss({ + enabled: visible, + dismissable: dismissableBackButton, + onDismiss: onDismissCallback, + }); const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 0083a28f84..eaaba604fd 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -1,6 +1,14 @@ import { BackHandler as RNBackHandler, Text } from 'react-native'; -import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; +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,6 +25,7 @@ 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 @@ -39,6 +48,12 @@ 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(); + }); + it('renders passed children', async () => { await render( @@ -510,6 +525,22 @@ 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('runs the fade-in animation when visible changes from false to true', async () => { const { rerender, toJSON } = await render( diff --git a/src/utils/__tests__/useOverlayDismiss.test.tsx b/src/utils/__tests__/useOverlayDismiss.test.tsx new file mode 100644 index 0000000000..abff7f5aff --- /dev/null +++ b/src/utils/__tests__/useOverlayDismiss.test.tsx @@ -0,0 +1,308 @@ +import { BackHandler as RNBackHandler, Platform, Text } from 'react-native'; + +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act } from '@testing-library/react-native'; + +import Modal from '../../components/Modal'; +import Portal from '../../components/Portal/Portal'; +import { render, screen } from '../../test-utils'; +import { useOverlayDismiss } from '../useOverlayDismiss'; + +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 Probe = ({ + label, + visible, + dismissable = true, + onDismiss, +}: { + label: string; + visible: boolean; + dismissable?: boolean; + onDismiss?: () => void; +}) => { + useOverlayDismiss({ enabled: visible, dismissable, onDismiss }); + + return visible ? {label} : null; +}; + +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 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. + Object.defineProperty(global, 'document', { + configurable: true, + value: { + 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; + }); + + 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('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/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]); +} From 31d9f2f8b063c24d81f66262c2e60683d4326566 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Fri, 18 Sep 2026 15:46:17 +0200 Subject: [PATCH 2/4] feat(modal): move focus into the modal and hand it back on close Opening a modal now moves focus to its content, and closing it returns focus to whatever had it before. On web the first tabbable element is focused, since only the DOM can work out what that is; on native an accessibility focus event is sent instead. Focus waits for the fade in either direction: the content is not in the accessibility tree until it is opaque, and the app behind the modal only stops being inert once the modal has gone. Adds `initialFocusRef` and `restoreFocusRef` so an app can name the element to focus on open and the control to return to on close. --- jest/reanimatedSnapshotSerializer.js | 3 + jest/testSetup.js | 2 + src/components/Dialog/Dialog.tsx | 14 +- src/components/Modal.tsx | 33 +++ src/components/__tests__/Modal.test.tsx | 99 ++++++- .../__snapshots__/FABExtended.test.tsx.snap | 6 - src/utils/__tests__/useOverlayFocus.test.tsx | 268 ++++++++++++++++++ src/utils/useOverlayFocus.tsx | 140 +++++++++ 8 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 src/utils/__tests__/useOverlayFocus.test.tsx create mode 100644 src/utils/useOverlayFocus.tsx 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/Modal.tsx b/src/components/Modal.tsx index d9c6599443..0614e419b4 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -16,6 +16,7 @@ import { useInternalTheme } from '../core/theming'; import { tokens } from '../theme/tokens'; import type { Elevation, ThemeProp } from '../theme/types'; import { useOverlayDismiss } from '../utils/useOverlayDismiss'; +import { useOverlayFocus } from '../utils/useOverlayFocus'; const scrimAlpha = tokens.md.sys.scrim.alpha; @@ -53,6 +54,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`. */ @@ -137,6 +146,8 @@ function Modal({ dismissable = true, dismissableBackButton = dismissable, visible = false, + initialFocusRef, + restoreFocusRef, dismissAccessibilityLabel = 'Close modal', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, @@ -191,6 +202,27 @@ function Modal({ onDismiss: onDismissCallback, }); + const contentRef = React.useRef>(null); + + const { focusInitialTarget } = useOverlayFocus({ + visible: visibleInternal, + containerRef: contentRef, + initialFocusRef, + restoreFocusRef, + }); + + React.useEffect(() => { + if (!animatedVisible) { + return undefined; + } + + // 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 () => clearTimeout(timeout); + }, [animatedVisible, focusInitialTarget, scale]); + const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); const backdropTransitionStyle: AnimatedStyle = { @@ -244,6 +276,7 @@ function Modal({ pointerEvents="box-none" > { + for (let pass = 0; pass < 5; pass += 1) { + await act(() => { + jest.advanceTimersByTime(1000); + }); + + if (jest.getTimerCount() === 0) { + return; + } + } +}; + describe('Modal', () => { beforeAll(() => { jest.useFakeTimers(); @@ -52,6 +76,7 @@ describe('Modal', () => { // and nothing clears it globally. beforeEach(() => { BackHandler.exitApp.mockClear(); + sendAccessibilityEvent.mockClear(); }); it('renders passed children', async () => { @@ -541,6 +566,78 @@ describe('Modal', () => { 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 settle(); + + expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1); + expect(sendAccessibilityEvent).toHaveBeenCalledWith( + restoreFocusRef.current, + 'focus' + ); + }); + 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`] = ` { + 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 } = useOverlayFocus({ + visible, + containerRef: contentRef, + initialFocusRef, + restoreFocusRef, + }); + + 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.advanceTimersByTime(1); + }); +}; + +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 once however often the content lays out', async () => { + const contentRef = React.createRef>(); + + await render(); + await layOutContent(); + await layOutContent(); + await layOutContent(); + + expect(sendAccessibilityEvent).toHaveBeenCalledTimes(1); + }); + }); + + 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('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/useOverlayFocus.tsx b/src/utils/useOverlayFocus.tsx new file mode 100644 index 0000000000..41f4169122 --- /dev/null +++ b/src/utils/useOverlayFocus.tsx @@ -0,0 +1,140 @@ +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( + (element) => isVisible(element) && element.closest('[inert]') === null + ) ?? null; + +/** Prefer `target`. Search children only if the browser refused focus. */ +const focusDOMTarget = (target: HTMLElement) => { + target.focus(); + + if (document.activeElement !== target) { + findFirstTabbable(target)?.focus(); + } +}; + +export type OverlayFocusOptions = { + /** + * Whether the overlay is visible. + */ + 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`, that should be called once the overlay can take focus. + */ +export function useOverlayFocus({ + visible, + containerRef, + initialFocusRef, + restoreFocusRef, +}: OverlayFocusOptions) { + const hasMovedInitialFocus = React.useRef(false); + const focusedBeforeOpen = React.useRef(null); + + const focusInitialTarget = useLatestCallback(() => { + if (!visible || hasMovedInitialFocus.current) { + return; + } + + const target = initialFocusRef?.current ?? containerRef.current; + + if (!target) { + return; + } + + if (Platform.OS === 'web') { + if (!(target instanceof HTMLElement)) { + return; + } + + // The first interactive element, which only the DOM can work out. + hasMovedInitialFocus.current = true; + focusDOMTarget( + initialFocusRef?.current + ? target + : (findFirstTabbable(target) ?? target) + ); + + return; + } + + hasMovedInitialFocus.current = true; + AccessibilityInfo.sendAccessibilityEvent(target, 'focus'); + }); + + const restoreFocus = useLatestCallback(() => { + if (Platform.OS === 'web') { + const target = restoreFocusRef?.current ?? focusedBeforeOpen.current; + + focusedBeforeOpen.current = null; + + // A target that has left the document is left to the browser. + if (target instanceof HTMLElement && target.isConnected) { + focusDOMTarget(target); + } + + return; + } + + if (restoreFocusRef?.current) { + AccessibilityInfo.sendAccessibilityEvent( + restoreFocusRef?.current, + 'focus' + ); + } + }); + + React.useEffect(() => { + if (!visible) { + return undefined; + } + + if (Platform.OS === 'web' && 'document' in global) { + const activeElement = document.activeElement; + + focusedBeforeOpen.current = + activeElement instanceof HTMLElement ? activeElement : null; + } + + return () => { + if (!hasMovedInitialFocus.current) { + return; + } + + hasMovedInitialFocus.current = false; + + setTimeout(restoreFocus, 0); + }; + }, [visible, restoreFocus]); + + return { focusInitialTarget }; +} From 1eff8fc67afdcfb818d3a5967a0e9201b40f97d6 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 21 Sep 2026 01:47:05 +0200 Subject: [PATCH 3/4] refactor(modal): hand focus back on close instead of on unmount `useOverlayFocus` now returns `restoreFocus`, so the overlay decides when focus goes back rather than having it fire from an unmount cleanup that only ran once the content was already gone. Restoring waits a tick, so the close render has removed background inertness before the previous element is focused again. Reopening mid-close keeps the element that first opened the overlay, rather than capturing something inside the overlay that is still on its way out. Each focus move now reports whether it landed, through a web half that takes a DOM node and a native half that takes a ref, so an overlay that cannot take focus no longer leaves the hook believing focus moved. --- src/components/Modal.tsx | 24 ++-- src/components/__tests__/Modal.test.tsx | 10 ++ src/utils/__tests__/useOverlayFocus.test.tsx | 130 +++++++++++++++++- src/utils/useOverlayFocus.tsx | 131 +++++++++++-------- 4 files changed, 223 insertions(+), 72 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 0614e419b4..290841fee3 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -177,6 +177,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); @@ -196,21 +205,18 @@ function Modal({ return () => clearTimeout(timeout); }, [scale, visible, visibleInternal]); + React.useEffect(() => { + if (!visibleInternal) { + restoreFocus(); + } + }, [restoreFocus, visibleInternal]); + useOverlayDismiss({ enabled: visible, dismissable: dismissableBackButton, onDismiss: onDismissCallback, }); - const contentRef = React.useRef>(null); - - const { focusInitialTarget } = useOverlayFocus({ - visible: visibleInternal, - containerRef: contentRef, - initialFocusRef, - restoreFocusRef, - }); - React.useEffect(() => { if (!animatedVisible) { return undefined; diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 56f4efa8f0..abce45cfc5 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -629,6 +629,15 @@ describe('Modal', () => { ); + 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); @@ -636,6 +645,7 @@ describe('Modal', () => { restoreFocusRef.current, 'focus' ); + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); }); it('runs the fade-in animation when visible changes from false to true', async () => { diff --git a/src/utils/__tests__/useOverlayFocus.test.tsx b/src/utils/__tests__/useOverlayFocus.test.tsx index 3db892d2e2..945ca46f6b 100644 --- a/src/utils/__tests__/useOverlayFocus.test.tsx +++ b/src/utils/__tests__/useOverlayFocus.test.tsx @@ -36,13 +36,19 @@ const Overlay = ({ initialFocusRef, restoreFocusRef, }: OverlayProps) => { - const { focusInitialTarget } = useOverlayFocus({ + const { focusInitialTarget, restoreFocus } = useOverlayFocus({ visible, containerRef: contentRef, initialFocusRef, restoreFocusRef, }); + React.useEffect(() => { + if (!visible) { + restoreFocus(); + } + }, [restoreFocus, visible]); + return ( ); @@ -55,7 +61,7 @@ const layOutContent = async () => { /** Focus is handed back a tick after the overlay closes. */ const settle = async () => { await act(() => { - jest.advanceTimersByTime(1); + jest.runOnlyPendingTimers(); }); }; @@ -97,15 +103,53 @@ describe('useOverlayFocus', () => { ); }); - it('moves focus once however often the content lays out', async () => { + it('moves focus again when the overlay reopens', async () => { const contentRef = React.createRef>(); + const restoreFocusRef = + React.createRef>(); - await render(); - await layOutContent(); + 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' + ); }); }); @@ -195,6 +239,82 @@ describe('useOverlayFocus', () => { 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>(); diff --git a/src/utils/useOverlayFocus.tsx b/src/utils/useOverlayFocus.tsx index 41f4169122..93be18a0f4 100644 --- a/src/utils/useOverlayFocus.tsx +++ b/src/utils/useOverlayFocus.tsx @@ -21,22 +21,40 @@ const isVisible = (element: HTMLElement) => const findFirstTabbable = (container: HTMLElement) => Array.from(container.querySelectorAll(TABBABLE_SELECTOR)).find( - (element) => isVisible(element) && element.closest('[inert]') === null + isVisible ) ?? null; -/** Prefer `target`. Search children only if the browser refused focus. */ -const focusDOMTarget = (target: HTMLElement) => { +// 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 visible. - */ + /** 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>; @@ -49,7 +67,10 @@ export type OverlayFocusOptions = { /** * Moves focus into an overlay when it opens and back when it closes. * - * @returns `focusInitialTarget`, that should be called once the overlay can take focus. + * @returns + * + * `focusInitialTarget`, to call once the overlay can take focus. + * `restoreFocus`, to call once it can no longer. */ export function useOverlayFocus({ visible, @@ -58,7 +79,9 @@ export function useOverlayFocus({ restoreFocusRef, }: OverlayFocusOptions) { const hasMovedInitialFocus = React.useRef(false); - const focusedBeforeOpen = React.useRef(null); + const hasRestoredFocus = React.useRef(false); + const focusedWebElementBeforeOpen = React.useRef(null); + const restoreTimeout = React.useRef>(undefined); const focusInitialTarget = useLatestCallback(() => { if (!visible || hasMovedInitialFocus.current) { @@ -67,74 +90,66 @@ export function useOverlayFocus({ const target = initialFocusRef?.current ?? containerRef.current; - if (!target) { - return; - } - - if (Platform.OS === 'web') { - if (!(target instanceof HTMLElement)) { - return; - } - - // The first interactive element, which only the DOM can work out. - hasMovedInitialFocus.current = true; - focusDOMTarget( - initialFocusRef?.current - ? target - : (findFirstTabbable(target) ?? target) - ); + 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; } - hasMovedInitialFocus.current = true; - AccessibilityInfo.sendAccessibilityEvent(target, 'focus'); - }); + hasRestoredFocus.current = true; - const restoreFocus = useLatestCallback(() => { - if (Platform.OS === 'web') { - const target = restoreFocusRef?.current ?? focusedBeforeOpen.current; + // Wait for the close render to remove background inertness. + restoreTimeout.current = setTimeout(() => { + const elementBeforeOpen = focusedWebElementBeforeOpen.current; - focusedBeforeOpen.current = null; + focusedWebElementBeforeOpen.current = null; - // A target that has left the document is left to the browser. - if (target instanceof HTMLElement && target.isConnected) { - focusDOMTarget(target); - } + if (Platform.OS === 'web') { + moveWebFocusTo( + restoreFocusRef ? restoreFocusRef.current : elementBeforeOpen + ); - return; - } + return; + } - if (restoreFocusRef?.current) { - AccessibilityInfo.sendAccessibilityEvent( - restoreFocusRef?.current, - 'focus' - ); - } + moveNativeFocusTo(restoreFocusRef?.current ?? null); + }, 0); }); React.useEffect(() => { if (!visible) { - return undefined; + 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; - - focusedBeforeOpen.current = - activeElement instanceof HTMLElement ? activeElement : null; - } - - return () => { - if (!hasMovedInitialFocus.current) { - return; + 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]); - hasMovedInitialFocus.current = false; - - setTimeout(restoreFocus, 0); - }; - }, [visible, restoreFocus]); + React.useEffect(() => () => restoreFocus(), [restoreFocus]); - return { focusInitialTarget }; + return { focusInitialTarget, restoreFocus }; } From 3d8e2cf2328ebafed7a79a890c1f91ddf07d4089 Mon Sep 17 00:00:00 2001 From: Nikolay Arefyev Date: Fri, 6 Nov 2026 13:53:34 +0200 Subject: [PATCH 4/4] feat: add required feedback and web tests --- docs/6.x/docs/guides/migration.md | 20 ++ src/components/Menu/Menu.tsx | 36 ++-- src/components/Modal.tsx | 18 +- .../__tests__/MenuInDialog.test.tsx | 186 ++++++++++++++++++ src/components/__tests__/Modal.test.tsx | 33 ++++ .../__snapshots__/Modal.test.tsx.snap | 8 + .../__tests__/useOverlayDismiss.test.tsx | 65 ++++++ 7 files changed, 338 insertions(+), 28 deletions(-) create mode 100644 src/components/__tests__/MenuInDialog.test.tsx 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/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 290841fee3..55b834b224 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -22,15 +22,21 @@ 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; /** @@ -102,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); /** @@ -261,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; +}) => ( + + + + Open menu} + > + + + + + +); + +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 abce45cfc5..60d3c6c6c1 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -459,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(); diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index 8cf23e1346..e1ccb2edb9 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -28,6 +28,7 @@ exports[`Modal closes a non-dismissible modal when visible changes from true to {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(); @@ -76,6 +97,24 @@ describe('useOverlayDismiss', () => { 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(); @@ -211,9 +250,16 @@ describe('useOverlayDismiss', () => { // 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, @@ -236,6 +282,7 @@ describe('useOverlayDismiss', () => { afterAll(() => { platform.restore(); delete (global as { document?: unknown }).document; + delete (global as { HTMLElement?: unknown }).HTMLElement; }); beforeEach(() => { @@ -276,6 +323,24 @@ describe('useOverlayDismiss', () => { 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();