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; +}) => ( + + + + 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 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 }; +}