From 6952ce08049d52732f49a0cc3b3ec16aa8fa6285 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 14 Sep 2026 18:22:53 +0200 Subject: [PATCH 01/11] fix(modal): honour dismissableBackButton independently of dismissable Test `dismissableBackButton` alone instead of `dismissable || dismissableBackButton`, which short-circuited on `dismissable` so the prop only had an effect when `dismissable` was already `false`. --- example/src/Examples/DialogExample.tsx | 14 +++++++++ .../DialogWithUndismissableBackButton.tsx | 31 +++++++++++++++++++ example/src/Examples/Dialogs/index.tsx | 1 + src/components/Modal.tsx | 2 +- src/components/__tests__/Modal.test.tsx | 31 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx diff --git a/example/src/Examples/DialogExample.tsx b/example/src/Examples/DialogExample.tsx index d926ae6616..b12cbb5c84 100644 --- a/example/src/Examples/DialogExample.tsx +++ b/example/src/Examples/DialogExample.tsx @@ -10,6 +10,7 @@ import { DialogWithLoadingIndicator, DialogWithLongText, DialogWithRadioBtns, + DialogWithUndismissableBackButton, UndismissableDialog, } from './Dialogs'; import ScreenWrapper from '../ScreenWrapper'; @@ -79,6 +80,15 @@ const DialogExample = () => { Dismissable back button )} + {Platform.OS === 'android' && ( + + )} { visible={_getVisible('dialog7')} close={_toggleDialog('dialog7')} /> + ); }; diff --git a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx new file mode 100644 index 0000000000..434b4ad154 --- /dev/null +++ b/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx @@ -0,0 +1,31 @@ +import { Button, Portal, Dialog, Palette } from 'react-native-paper'; + +import { TextComponent } from './DialogTextComponent'; + +const DialogWithUndismissableBackButton = ({ + visible, + close, +}: { + visible: boolean; + close: () => void; +}) => ( + + + Alert + + + This dialog can be dismissed by tapping outside, however the hardware + back button will not close it! + + + + + + + + +); + +export default DialogWithUndismissableBackButton; diff --git a/example/src/Examples/Dialogs/index.tsx b/example/src/Examples/Dialogs/index.tsx index 7af735d036..5ae48db11f 100644 --- a/example/src/Examples/Dialogs/index.tsx +++ b/example/src/Examples/Dialogs/index.tsx @@ -5,3 +5,4 @@ export { default as DialogWithRadioBtns } from './DialogWithRadioBtns'; export { default as UndismissableDialog } from './UndismissableDialog'; export { default as DialogWithIcon } from './DialogWithIcon'; export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton'; +export { default as DialogWithUndismissableBackButton } from './DialogWithUndismissableBackButton'; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index fbc1197630..7ef4c80c3d 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -182,7 +182,7 @@ function Modal({ } const onHardwareBackPress = () => { - if (dismissable || dismissableBackButton) { + if (dismissableBackButton) { onDismissCallback(); } diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 657eaf0992..7d03174a2c 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -219,6 +219,37 @@ describe('Modal', () => { expect(toJSON()).toMatchSnapshot(); }); + it('should not invoke onDismiss on back press when only dismissableBackButton is false', async () => { + const onDismiss = jest.fn(); + + await render( + + {null} + + ); + + await act(() => { + BackHandler.mockPressBack(); + jest.runAllTimers(); + }); + + expect(onDismiss).not.toHaveBeenCalled(); + + // Pressing outside still dismisses it -- only the back button is opted out. + await userEvent.press(screen.getByLabelText('Close modal')); + + await act(() => { + jest.runAllTimers(); + }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + it('should not invoke onDismiss', async () => { const onDismiss = jest.fn(); await render( From 9704537d2129adce23ae158e409bcb39be0760c4 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 14 Sep 2026 18:24:41 +0200 Subject: [PATCH 02/11] feat(modal): expose the modal as a named dialog to assistive technology Add `role="dialog"` to the content `Surface` and accept an `aria-label`, so the role and the name sit on the element that actually is the dialog. Drop `aria-modal`: it was set on the full-screen wrapper rather than on the dialog, and this component does not make the content behind the modal inert, so claiming modality would have misdescribed it. --- src/components/Modal.tsx | 11 +++- src/components/__tests__/Modal.test.tsx | 10 ++++ .../__snapshots__/Modal.test.tsx.snap | 50 +++++++++---------- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 7ef4c80c3d..80fc925cf3 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -36,6 +36,10 @@ export type Props = { * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the modal. */ overlayAccessibilityLabel?: string; + /** + * Accessible name for the modal. + */ + 'aria-label'?: string; /** * testID for the overlay that is displayed behind the modal content. */ @@ -89,7 +93,7 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable); /** * The Modal component is a simple way to present content above an enclosing view. * To render the `Modal` above other components, you'll need to wrap it with the [`Portal`](./Portal) component. - * Note that this modal is NOT accessible by default; if you need an accessible modal, please use the React Native Modal. + * Give the modal an accessible name with `aria-label`. * * ## Usage * ```js @@ -110,6 +114,7 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable); * @@ -131,6 +136,7 @@ function Modal({ dismissableBackButton = dismissable, visible = false, overlayAccessibilityLabel = 'Close modal', + 'aria-label': ariaLabel, overlayTestID, onDismiss = () => {}, children, @@ -227,7 +233,6 @@ function Modal({ return ( { }); }); }); + +it('exposes the modal as a dialog with an accessible name', async () => { + await render( + {}} aria-label="Example modal"> + Modal content + + ); + + expect(screen.getByLabelText('Example modal')).toHaveProp('role', 'dialog'); +}); diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index 5b36c6a27a..8708454b35 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -3,7 +3,6 @@ exports[`Modal by default should receive appropriate top and bottom insets 1`] = ` Date: Tue, 15 Sep 2026 12:20:46 +0200 Subject: [PATCH 03/11] feat(modal): let assistive technology reach the scrim The scrim carries its own accessible name and closes the modal when it is pressed, but `importantForAccessibility="no"` kept it out of the screen reader's reach, so the one control that dismisses the modal was announced to nobody. Tie `accessible` to `dismissable` so the scrim is only announced when pressing it does something. A non-dismissible modal would otherwise offer a "Close modal" button that ignores every press. --- src/components/Modal.tsx | 2 +- .../__snapshots__/Modal.test.tsx.snap | 41 ++++--------------- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 80fc925cf3..7c1b2b4d8d 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -243,9 +243,9 @@ function Modal({ role="button" disabled={!dismissable} onPress={dismissable ? onDismissCallback : undefined} - importantForAccessibility="no" style={[styles.backdrop, backdropStyle, backdropTransitionStyle]} testID={overlayTestID} + accessible={dismissable} /> Date: Tue, 15 Sep 2026 12:23:09 +0200 Subject: [PATCH 04/11] fix(modal): stop the escape gesture closing a non-dismissible modal `dismissable={false}` keeps the scrim from closing the modal, but the iOS escape gesture closed it anyway, so a screen reader user could leave a modal the interface was holding them in. --- src/components/Modal.tsx | 2 +- src/components/__tests__/Modal.test.tsx | 40 ++++++++++++++++++- .../__snapshots__/Modal.test.tsx.snap | 8 ---- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 7c1b2b4d8d..48f0f6c73b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -235,7 +235,7 @@ function Modal({ pointerEvents={visible ? 'auto' : 'none'} aria-live="polite" style={StyleSheet.absoluteFill} - onAccessibilityEscape={onDismissCallback} + onAccessibilityEscape={dismissable ? onDismissCallback : undefined} testID={testID} > { expect(toJSON()).toBeNull(); }); + describe('if closed via the accessibility escape gesture', () => { + it('should invoke the onDismiss function', async () => { + const onDismiss = jest.fn(); + + await render( + + {null} + + ); + + await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); + describe('if closed via Android back button', () => { it('invokes onDismiss', async () => { const onDismiss = jest.fn(); @@ -277,6 +293,28 @@ describe('Modal', () => { }); }); + describe('if closed via the accessibility escape gesture', () => { + it('should keep the modal on screen', async () => { + const onDismiss = jest.fn(); + + await render( + + {null} + + ); + + await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Close modal')).toBeOnTheScreen(); + }); + }); + describe('if closed via Android back button', () => { it('will run the animation but not fade out', async () => { const { toJSON } = await render( diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index fa868ae2d6..7c0ee4cc58 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -186,7 +186,6 @@ exports[`Modal when open as non-dismissible modal if closed via Android back but Date: Tue, 15 Sep 2026 17:45:11 +0200 Subject: [PATCH 05/11] fix(modal): mark the dialog surface as modal for assistive technology `aria-modal` was dropped from the component while reworking the modal's accessibility, leaving nothing to scope assistive technology to the dialog. On iOS it maps to `accessibilityViewIsModal`, which is the only mechanism that takes effect there: `role` and `aria-label` are both ignored by VoiceOver on native, so without it a screen reader could reach the content behind an open modal. Put it on the content `Surface` rather than the full-screen wrapper, so the boundary matches the element that is the dialog. --- src/components/Modal.tsx | 1 + .../__snapshots__/Modal.test.tsx.snap | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 48f0f6c73b..1580620955 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -257,6 +257,7 @@ function Modal({ > Date: Tue, 15 Sep 2026 17:45:41 +0200 Subject: [PATCH 06/11] feat(modal): let the caller set the role exposed to assistive technology The role was hardcoded to `dialog`, which suits most modals but not one that interrupts the user with an urgent message. Accept a `role` prop, typed as React Native's `Role` and defaulting to `dialog`, so `alertdialog` and the other ARIA roles are reachable without forking the component. Note that native platforms ignore this: React Native maps every dialog-ish role to `UIAccessibilityTraitNone` on iOS, and TalkBack does not announce it either. It carries on react-native-web, where the role is honoured. --- src/components/Modal.tsx | 9 +++++++-- src/components/__tests__/Modal.test.tsx | 10 ++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 1580620955..040c82fb71 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { StyleSheet, Pressable, View } from 'react-native'; -import type { StyleProp, ViewStyle } from 'react-native'; +import type { Role, StyleProp, ViewStyle } from 'react-native'; import Animated, { cubicBezier, @@ -40,6 +40,10 @@ export type Props = { * Accessible name for the modal. */ 'aria-label'?: string; + /** + * Role exposed to assistive technology. Defaults to `dialog`. + */ + role?: Role; /** * testID for the overlay that is displayed behind the modal content. */ @@ -137,6 +141,7 @@ function Modal({ visible = false, overlayAccessibilityLabel = 'Close modal', 'aria-label': ariaLabel, + role = 'dialog', overlayTestID, onDismiss = () => {}, children, @@ -256,7 +261,7 @@ function Modal({ pointerEvents="box-none" > { }); }); +it('exposes the modal with a caller-provided role', async () => { + await render( + {}} aria-label="Urgent" role="alertdialog"> + Modal content + + ); + + expect(screen.getByLabelText('Urgent')).toHaveProp('role', 'alertdialog'); +}); + it('exposes the modal as a dialog with an accessible name', async () => { await render( {}} aria-label="Example modal"> From 69b6a33432931edf5c5e0d626586e1bcd799df20 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Tue, 15 Sep 2026 17:47:37 +0200 Subject: [PATCH 07/11] fix(modal)!: hide the scrim from assistive technology The scrim is a visual affordance, not a control worth announcing. Exposing it gave screen reader users a "Close modal" button that duplicates the escape gesture and the back button, and on Android it was announced as disabled whenever `dismissable` was `false`. Hide it with `aria-hidden`, which covers both platforms: React Native maps it to `accessibilityElementsHidden` on iOS and `importantForAccessibility="no-hide-descendants"` on Android. With the scrim out of the accessibility tree its label can no longer be read by anything, so `overlayAccessibilityLabel` is removed rather than left as a prop that does nothing. Tests locate the scrim through `overlayTestID`, which already existed for that purpose. BREAKING CHANGE: `overlayAccessibilityLabel` is removed from `Modal`. The scrim is no longer exposed to assistive technology, so the prop had no remaining effect. Use `overlayTestID` to target the scrim in tests. --- src/components/Modal.tsx | 8 +- src/components/__tests__/Dialog.test.tsx | 31 ++- src/components/__tests__/Modal.test.tsx | 211 ++++++++++++++---- .../__snapshots__/Modal.test.tsx.snap | 91 +++++--- 4 files changed, 247 insertions(+), 94 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 040c82fb71..81765e22dc 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -32,10 +32,6 @@ export type Props = { * Callback that is called when the user dismisses the modal. */ onDismiss?: () => void; - /** - * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the modal. - */ - overlayAccessibilityLabel?: string; /** * Accessible name for the modal. */ @@ -139,7 +135,6 @@ function Modal({ dismissable = true, dismissableBackButton = dismissable, visible = false, - overlayAccessibilityLabel = 'Close modal', 'aria-label': ariaLabel, role = 'dialog', overlayTestID, @@ -244,13 +239,12 @@ function Modal({ testID={testID} > { it('should render passed children', async () => { await render( - + This is simple dialog ); @@ -36,12 +36,20 @@ describe('Dialog', () => { it('should call onDismiss when dismissable', async () => { const onDismiss = jest.fn(); await render( - + This is simple dialog ); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); @@ -52,12 +60,20 @@ describe('Dialog', () => { it('should not call onDismiss when dismissable is false', async () => { const onDismiss = jest.fn(); await render( - + This is simple dialog ); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); @@ -75,12 +91,15 @@ describe('Dialog', () => { dismissable={false} dismissableBackButton testID="dialog" + overlayTestID="backdrop" > This is simple dialog ); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 77360e2041..df6e8348f9 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -42,7 +42,7 @@ describe('Modal', () => { describe('by default', () => { it('should render passed children', async () => { await render( - + Children ); @@ -52,12 +52,14 @@ describe('Modal', () => { it("should render a backdrop in default theme's color", async () => { await render( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ backgroundColor: LightTheme.colors.scrim, }); }); @@ -67,6 +69,7 @@ describe('Modal', () => { { ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ backgroundColor: 'transparent', }); }); it('should receive appropriate top and bottom insets', async () => { const { toJSON } = await render( - + {null} ); @@ -93,18 +98,38 @@ describe('Modal', () => { }); }); describe('when open', () => { + it('should hide the scrim from assistive technology', async () => { + await render( + + {null} + + ); + + expect(screen.queryByTestId('backdrop')).not.toBeOnTheScreen(); + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toBeOnTheScreen(); + }); + describe('if backdrop touched', () => { it('should invoke the onDismiss function immediately', async () => { const onDismiss = jest.fn(); const { toJSON } = await render( - + {null} ); expect(onDismiss).not.toHaveBeenCalled(); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); expect(onDismiss).toHaveBeenCalled(); @@ -114,7 +139,9 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); @@ -126,24 +153,38 @@ describe('Modal', () => { it('runs the closing animation if visible toggled', async () => { const { rerender, toJSON } = await render( - {}}> + {}} + > {null} ); expect(toJSON()).toMatchSnapshot(); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await rerender( - {}}> + {}} + > {null} ); expect(toJSON()).toMatchSnapshot(); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); @@ -161,7 +202,12 @@ describe('Modal', () => { const onDismiss = jest.fn(); await render( - + {null} ); @@ -176,7 +222,12 @@ describe('Modal', () => { it('invokes onDismiss', async () => { const onDismiss = jest.fn(); const { toJSON } = await render( - + {null} ); @@ -193,7 +244,9 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); @@ -210,6 +263,7 @@ describe('Modal', () => { const { toJSON } = await render( {}} dismissable={false} @@ -220,7 +274,9 @@ describe('Modal', () => { expect(toJSON()).toMatchSnapshot(); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); expect(toJSON()).toMatchSnapshot(); @@ -228,7 +284,9 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); @@ -241,6 +299,7 @@ describe('Modal', () => { await render( { expect(onDismiss).not.toHaveBeenCalled(); // Pressing outside still dismisses it -- only the back button is opted out. - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); @@ -271,6 +332,7 @@ describe('Modal', () => { await render( { expect(onDismiss).not.toHaveBeenCalled(); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); expect(onDismiss).not.toHaveBeenCalled(); @@ -300,6 +364,7 @@ describe('Modal', () => { await render( { await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); expect(onDismiss).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Close modal')).toBeOnTheScreen(); + expect(screen.getByTestId('modal')).toBeOnTheScreen(); }); }); @@ -320,6 +385,7 @@ describe('Modal', () => { const { toJSON } = await render( {}} dismissable={false} @@ -340,7 +406,9 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); @@ -353,6 +421,7 @@ describe('Modal', () => { await render( { describe('from false to true (closed to open)', () => { it('should run fade-in animation on opening', async () => { const { rerender, toJSON } = await render( - + {null} ); @@ -390,12 +459,14 @@ describe('Modal', () => { expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); await rerender( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: 0, }); expect(toJSON()).toMatchSnapshot(); @@ -404,7 +475,9 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -414,23 +487,27 @@ describe('Modal', () => { describe('from true to false (open to closed)', () => { it('should run fade-out animation on closing', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); await rerender( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -446,7 +523,12 @@ describe('Modal', () => { const onDismiss = jest.fn(); const { rerender } = await render( - + {null} ); @@ -454,7 +536,12 @@ describe('Modal', () => { expect(onDismiss).not.toHaveBeenCalled(); await rerender( - + {null} ); @@ -470,23 +557,37 @@ describe('Modal', () => { it('should close even if the dialog is not dismissible', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); await rerender( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -504,23 +605,27 @@ describe('Modal', () => { describe('while closing, back to true (visible)', () => { it('should keep the modal open', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); await rerender( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -532,7 +637,7 @@ describe('Modal', () => { }); await rerender( - + {null} ); @@ -541,7 +646,9 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -551,20 +658,24 @@ describe('Modal', () => { describe('while opening, back to false (hidden)', () => { it('should keep the modal closed', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect(screen.queryByLabelText('Close modal')).not.toBeOnTheScreen(); + expect( + screen.queryByTestId('backdrop', { includeHiddenElements: true }) + ).not.toBeOnTheScreen(); await rerender( - + {null} ); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ opacity: 0, }); expect(toJSON()).toMatchSnapshot(); @@ -575,10 +686,12 @@ describe('Modal', () => { jest.advanceTimersToNextTimer(1000); }); - expect(screen.getByLabelText('Close modal')).toBeOnTheScreen(); + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toBeOnTheScreen(); await rerender( - + {null} ); @@ -587,7 +700,9 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect(screen.queryByLabelText('Close modal')).not.toBeOnTheScreen(); + expect( + screen.queryByTestId('backdrop', { includeHiddenElements: true }) + ).not.toBeOnTheScreen(); }); }); }); diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index 7edc48cf81..adfb3b63b7 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -20,7 +20,6 @@ exports[`Modal by default should receive appropriate top and bottom insets 1`] = testID="modal" > Date: Wed, 16 Sep 2026 15:59:46 +0200 Subject: [PATCH 08/11] fix(modal): drop the dead scrim role and stale effect dependency The scrim is hidden from assistive technology, so the role it declared no longer reaches anyone. The hardware back effect reads only dismissableBackButton now, so dismissable is no longer a dependency of it. --- src/components/Modal.tsx | 3 +-- .../__snapshots__/Modal.test.tsx.snap | 25 ------------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 81765e22dc..8a655fd985 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -202,7 +202,7 @@ function Modal({ ); return () => subscription.remove(); - }, [dismissable, dismissableBackButton, onDismissCallback, visible]); + }, [dismissableBackButton, onDismissCallback, visible]); const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); @@ -239,7 +239,6 @@ function Modal({ testID={testID} > Date: Thu, 17 Sep 2026 13:15:37 +0200 Subject: [PATCH 09/11] revert(modal): drop the caller-provided role prop The surface stays a dialog, but the prop lands together with the Dialog wiring in #5072 rather than half-done here. --- src/components/Modal.tsx | 9 ++------- src/components/__tests__/Modal.test.tsx | 10 ---------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 8a655fd985..ded923a72c 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { StyleSheet, Pressable, View } from 'react-native'; -import type { Role, StyleProp, ViewStyle } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; import Animated, { cubicBezier, @@ -36,10 +36,6 @@ export type Props = { * Accessible name for the modal. */ 'aria-label'?: string; - /** - * Role exposed to assistive technology. Defaults to `dialog`. - */ - role?: Role; /** * testID for the overlay that is displayed behind the modal content. */ @@ -136,7 +132,6 @@ function Modal({ dismissableBackButton = dismissable, visible = false, 'aria-label': ariaLabel, - role = 'dialog', overlayTestID, onDismiss = () => {}, children, @@ -254,7 +249,7 @@ function Modal({ pointerEvents="box-none" > { }); }); -it('exposes the modal with a caller-provided role', async () => { - await render( - {}} aria-label="Urgent" role="alertdialog"> - Modal content - - ); - - expect(screen.getByLabelText('Urgent')).toHaveProp('role', 'alertdialog'); -}); - it('exposes the modal as a dialog with an accessible name', async () => { await render( {}} aria-label="Example modal"> From 2f050432d7b36fe60944fd9bbfb086f814c8cdc3 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 17 Sep 2026 17:40:20 +0200 Subject: [PATCH 10/11] revert(modal): keep the scrim reachable by assistive technology --- src/components/Modal.tsx | 10 +- src/components/__tests__/Dialog.test.tsx | 31 +-- src/components/__tests__/Modal.test.tsx | 211 ++++-------------- .../__snapshots__/Modal.test.tsx.snap | 141 +++++++----- 4 files changed, 146 insertions(+), 247 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index ded923a72c..f24f0e986b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -32,6 +32,10 @@ export type Props = { * Callback that is called when the user dismisses the modal. */ onDismiss?: () => void; + /** + * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the modal. + */ + overlayAccessibilityLabel?: string; /** * Accessible name for the modal. */ @@ -131,6 +135,7 @@ function Modal({ dismissable = true, dismissableBackButton = dismissable, visible = false, + overlayAccessibilityLabel = 'Close modal', 'aria-label': ariaLabel, overlayTestID, onDismiss = () => {}, @@ -234,11 +239,14 @@ function Modal({ testID={testID} > { it('should render passed children', async () => { await render( - + This is simple dialog ); @@ -36,20 +36,12 @@ describe('Dialog', () => { it('should call onDismiss when dismissable', async () => { const onDismiss = jest.fn(); await render( - + This is simple dialog ); - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); await act(() => { jest.runAllTimers(); @@ -60,20 +52,12 @@ describe('Dialog', () => { it('should not call onDismiss when dismissable is false', async () => { const onDismiss = jest.fn(); await render( - + This is simple dialog ); - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); await act(() => { jest.runAllTimers(); @@ -91,15 +75,12 @@ describe('Dialog', () => { dismissable={false} dismissableBackButton testID="dialog" - overlayTestID="backdrop" > This is simple dialog ); - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); await act(() => { jest.runAllTimers(); diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 4662082ef8..c82dde6a0a 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -42,7 +42,7 @@ describe('Modal', () => { describe('by default', () => { it('should render passed children', async () => { await render( - + Children ); @@ -52,14 +52,12 @@ describe('Modal', () => { it("should render a backdrop in default theme's color", async () => { await render( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ backgroundColor: LightTheme.colors.scrim, }); }); @@ -69,7 +67,6 @@ describe('Modal', () => { { ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ backgroundColor: 'transparent', }); }); it('should receive appropriate top and bottom insets', async () => { const { toJSON } = await render( - + {null} ); @@ -98,38 +93,18 @@ describe('Modal', () => { }); }); describe('when open', () => { - it('should hide the scrim from assistive technology', async () => { - await render( - - {null} - - ); - - expect(screen.queryByTestId('backdrop')).not.toBeOnTheScreen(); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toBeOnTheScreen(); - }); - describe('if backdrop touched', () => { it('should invoke the onDismiss function immediately', async () => { const onDismiss = jest.fn(); const { toJSON } = await render( - + {null} ); expect(onDismiss).not.toHaveBeenCalled(); - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); expect(onDismiss).toHaveBeenCalled(); @@ -139,9 +114,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); @@ -153,38 +126,24 @@ describe('Modal', () => { it('runs the closing animation if visible toggled', async () => { const { rerender, toJSON } = await render( - {}} - > + {}}> {null} ); expect(toJSON()).toMatchSnapshot(); - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); await rerender( - {}} - > + {}}> {null} ); expect(toJSON()).toMatchSnapshot(); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); @@ -202,12 +161,7 @@ describe('Modal', () => { const onDismiss = jest.fn(); await render( - + {null} ); @@ -222,12 +176,7 @@ describe('Modal', () => { it('invokes onDismiss', async () => { const onDismiss = jest.fn(); const { toJSON } = await render( - + {null} ); @@ -244,9 +193,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); @@ -263,7 +210,6 @@ describe('Modal', () => { const { toJSON } = await render( {}} dismissable={false} @@ -274,9 +220,7 @@ describe('Modal', () => { expect(toJSON()).toMatchSnapshot(); - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); expect(toJSON()).toMatchSnapshot(); @@ -284,9 +228,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); @@ -299,7 +241,6 @@ describe('Modal', () => { await render( { expect(onDismiss).not.toHaveBeenCalled(); // Pressing outside still dismisses it -- only the back button is opted out. - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); await act(() => { jest.runAllTimers(); @@ -332,7 +271,6 @@ describe('Modal', () => { await render( { expect(onDismiss).not.toHaveBeenCalled(); - await userEvent.press( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ); + await userEvent.press(screen.getByLabelText('Close modal')); expect(onDismiss).not.toHaveBeenCalled(); @@ -364,7 +300,6 @@ describe('Modal', () => { await render( { await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); expect(onDismiss).not.toHaveBeenCalled(); - expect(screen.getByTestId('modal')).toBeOnTheScreen(); + expect(screen.getByLabelText('Close modal')).toBeOnTheScreen(); }); }); @@ -385,7 +320,6 @@ describe('Modal', () => { const { toJSON } = await render( {}} dismissable={false} @@ -406,9 +340,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); @@ -421,7 +353,6 @@ describe('Modal', () => { await render( { describe('from false to true (closed to open)', () => { it('should run fade-in animation on opening', async () => { const { rerender, toJSON } = await render( - + {null} ); @@ -459,14 +390,12 @@ describe('Modal', () => { expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); await rerender( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: 0, }); expect(toJSON()).toMatchSnapshot(); @@ -475,9 +404,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -487,27 +414,23 @@ describe('Modal', () => { describe('from true to false (open to closed)', () => { it('should run fade-out animation on closing', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); await rerender( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -523,12 +446,7 @@ describe('Modal', () => { const onDismiss = jest.fn(); const { rerender } = await render( - + {null} ); @@ -536,12 +454,7 @@ describe('Modal', () => { expect(onDismiss).not.toHaveBeenCalled(); await rerender( - + {null} ); @@ -557,37 +470,23 @@ describe('Modal', () => { it('should close even if the dialog is not dismissible', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); await rerender( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -605,27 +504,23 @@ describe('Modal', () => { describe('while closing, back to true (visible)', () => { it('should keep the modal open', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); await rerender( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -637,7 +532,7 @@ describe('Modal', () => { }); await rerender( - + {null} ); @@ -646,9 +541,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: scrimAlpha, }); expect(toJSON()).toMatchSnapshot(); @@ -658,24 +551,20 @@ describe('Modal', () => { describe('while opening, back to false (hidden)', () => { it('should keep the modal closed', async () => { const { rerender, toJSON } = await render( - + {null} ); - expect( - screen.queryByTestId('backdrop', { includeHiddenElements: true }) - ).not.toBeOnTheScreen(); + expect(screen.queryByLabelText('Close modal')).not.toBeOnTheScreen(); await rerender( - + {null} ); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toHaveStyle({ + expect(screen.getByLabelText('Close modal')).toHaveStyle({ opacity: 0, }); expect(toJSON()).toMatchSnapshot(); @@ -686,12 +575,10 @@ describe('Modal', () => { jest.advanceTimersToNextTimer(1000); }); - expect( - screen.getByTestId('backdrop', { includeHiddenElements: true }) - ).toBeOnTheScreen(); + expect(screen.getByLabelText('Close modal')).toBeOnTheScreen(); await rerender( - + {null} ); @@ -700,9 +587,7 @@ describe('Modal', () => { jest.runAllTimers(); }); - expect( - screen.queryByTestId('backdrop', { includeHiddenElements: true }) - ).not.toBeOnTheScreen(); + expect(screen.queryByLabelText('Close modal')).not.toBeOnTheScreen(); }); }); }); diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index 83daacdd63..5ba0c2a9dc 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -20,6 +20,7 @@ exports[`Modal by default should receive appropriate top and bottom insets 1`] = testID="modal" > Date: Mon, 28 Sep 2026 17:01:59 +0200 Subject: [PATCH 11/11] chore: a11y fixes and refactor --- docs/6.x/docs/guides/migration.md | 56 +- .../DialogWithUndismissableBackButton.tsx | 34 +- src/components/Dialog/Dialog.tsx | 38 +- src/components/Dialog/DialogTitle.tsx | 4 + src/components/Dialog/utils.ts | 5 + src/components/Menu/Menu.tsx | 6 +- src/components/Modal.tsx | 43 +- src/components/__tests__/Dialog.test.tsx | 81 +- src/components/__tests__/Modal.test.tsx | 1266 +++++++++-------- .../__snapshots__/Modal.test.tsx.snap | 1005 +++++++++++-- 10 files changed, 1753 insertions(+), 785 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 054288fec5..9b73d648ed 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -195,9 +195,35 @@ e.g.: ``` +The modal content now has the `dialog` role, so it needs an accessible name. You can provide one with the new `aria-label` prop: + +```diff +- ++ + Content + +``` + +The overlay behind the content is now hidden from assistive technology. Instead, when the modal is `dismissable`, screen reader users can dismiss it with a visually hidden button inside the dialog. + +The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`, which is used for the button's accessibility label: + +```diff + + Content + +``` + +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. + ### Dialog -`Dialog` now uses a `Modal` internally and doesn't require an explicit `Portal` wrapper. So you need to remove any existing `Portal` wrappers around `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`: ```diff - @@ -210,9 +236,37 @@ e.g.: + ``` +The dialog now has the `dialog` role. On web, the dialog's accessible name is set automatically by `Dialog.Title`. You can specify a different name with the new `aria-label` prop, e.g. when the dialog has no title: + +```jsx + + + Are you sure? + + +``` + +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`. - The `style` prop no longer configures the background color or border radius. You can override `theme.colors.surfaceContainerHigh` and `theme.shapes.corner.extraLarge` using the `theme` prop instead. +### Menu + +The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`: + +```diff + + + +``` + ### Searchbar The misspelled `traileringIcon` props have been renamed: diff --git a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx index 434b4ad154..d775ba011c 100644 --- a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx +++ b/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx @@ -1,4 +1,4 @@ -import { Button, Portal, Dialog, Palette } from 'react-native-paper'; +import { Button, Dialog, Palette } from 'react-native-paper'; import { TextComponent } from './DialogTextComponent'; @@ -9,23 +9,21 @@ const DialogWithUndismissableBackButton = ({ visible: boolean; close: () => void; }) => ( - - - Alert - - - This dialog can be dismissed by tapping outside, however the hardware - back button will not close it! - - - - - - - - + + Alert + + + This dialog can be dismissed by tapping outside, however the hardware + back button will not close it! + + + + + + + ); export default DialogWithUndismissableBackButton; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 9c75d4c08a..5b273151ec 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -13,6 +13,7 @@ import { useInternalTheme } from '../../core/theming'; import type { Elevation, ThemeProp } from '../../theme/types'; import Modal from '../Modal'; import type { SurfaceStyle } from '../Surface'; +import { DialogTitleIdContext } from './utils'; import type { DialogChildProps } from './utils'; export type Props = { @@ -28,6 +29,14 @@ export type Props = { * Callback that is called when the user dismisses the dialog. */ onDismiss?: () => void; + /** + * Accessibility label for dismissing the dialog if it's `dismissable`. + */ + dismissAccessibilityLabel?: string; + /** + * Accessible name for the dialog. On web, defaults to the text of `Dialog.Title`. + */ + 'aria-label'?: string; /** * Determines Whether the dialog is visible. */ @@ -95,6 +104,8 @@ const Dialog = ({ dismissable = true, dismissableBackButton = dismissable, onDismiss, + dismissAccessibilityLabel, + 'aria-label': ariaLabel, visible = false, style, theme: themeOverrides, @@ -108,11 +119,16 @@ const Dialog = ({ const backgroundColor = theme.colors.surfaceContainerHigh; + const titleId = React.useId(); + return ( - {React.Children.toArray(children) - .filter((child) => child != null && typeof child !== 'boolean') - .map((child, i) => { - if (i === 0 && React.isValidElement(child)) { - return React.cloneElement(child, { - style: [{ marginTop: 24 }, child.props.style], - }); - } + + {React.Children.toArray(children) + .filter((child) => child != null && typeof child !== 'boolean') + .map((child, i) => { + if (i === 0 && React.isValidElement(child)) { + return React.cloneElement(child, { + style: [{ marginTop: 24 }, child.props.style], + }); + } - return child; - })} + return child; + })} + ); }; diff --git a/src/components/Dialog/DialogTitle.tsx b/src/components/Dialog/DialogTitle.tsx index af36209cdc..4dd8bf8f5c 100644 --- a/src/components/Dialog/DialogTitle.tsx +++ b/src/components/Dialog/DialogTitle.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { StyleSheet } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; +import { DialogTitleIdContext } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import Text from '../Typography/Text'; @@ -48,10 +49,12 @@ const DialogTitle = ({ children, theme: themeOverrides, style, + nativeID, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); const { colors, fonts } = theme; + const titleId = React.useContext(DialogTitleIdContext); const headerTextStyle = { color: colors.onSurface, @@ -62,6 +65,7 @@ const DialogTitle = ({ diff --git a/src/components/Dialog/utils.ts b/src/components/Dialog/utils.ts index 64ccec77cc..40ca761321 100644 --- a/src/components/Dialog/utils.ts +++ b/src/components/Dialog/utils.ts @@ -1,5 +1,10 @@ +import * as React from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; +export const DialogTitleIdContext = React.createContext( + undefined +); + export type DialogChildProps = { style?: StyleProp; }; diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 96dd4096b7..6044956c06 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -67,7 +67,7 @@ export type Props = { /** * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the menu. */ - overlayAccessibilityLabel?: string; + dismissAccessibilityLabel?: string; /** * testID for the overlay that is displayed behind the menu. */ @@ -189,7 +189,7 @@ const isCoordinate = (anchor: any): anchor is { x: number; y: number } => const Menu = ({ visible, statusBarHeight, - overlayAccessibilityLabel = 'Close menu', + dismissAccessibilityLabel = 'Close menu', overlayTestID, testID, anchor, @@ -681,7 +681,7 @@ const Menu = ({ {rendered ? ( void; /** - * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the modal. + * Accessibility label for dismissing the modal if it's `dismissable`. */ - overlayAccessibilityLabel?: string; + dismissAccessibilityLabel?: string; /** * Accessible name for the modal. */ 'aria-label'?: string; + /** + * `nativeID` of the element which provides the accessible name for the modal, + * such as a title. Supported on web. + */ + 'aria-labelledby'?: string; /** * testID for the overlay that is displayed behind the modal content. */ @@ -133,8 +138,9 @@ function Modal({ dismissable = true, dismissableBackButton = dismissable, visible = false, - overlayAccessibilityLabel = 'Close modal', + dismissAccessibilityLabel = 'Close modal', 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, overlayTestID, onDismiss = () => {}, children, @@ -238,14 +244,13 @@ function Modal({ testID={testID} > {children} + {dismissable ? ( + // The backdrop is hidden for screen reader users, + // so we provide a visually hidden dismiss button. + + ) : null} @@ -292,4 +314,9 @@ const styles = StyleSheet.create({ content: { justifyContent: 'center', }, + dismiss: { + position: 'absolute', + width: 1, + height: 1, + }, }); diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index 129cf596f2..368bd9727f 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -6,7 +6,7 @@ import { } from 'react-native'; import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { act, userEvent } from '@testing-library/react-native'; import Dialog from '../../components/Dialog/Dialog'; @@ -21,8 +21,12 @@ interface BackHandlerStatic extends RNBackHandlerStatic { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const BackHandler = RNBackHandler as BackHandlerStatic; +afterEach(() => { + jest.restoreAllMocks(); +}); + describe('Dialog', () => { - it('should render passed children', async () => { + it('renders passed children', async () => { await render( @@ -36,82 +40,94 @@ describe('Dialog', () => { ); }); - it('should call onDismiss when dismissable', async () => { + it('invokes onDismiss when the visually hidden dismiss button is pressed', async () => { const onDismiss = jest.fn(); + await render( - + This is simple dialog ); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press(screen.getByRole('button', { name: 'Close modal' })); await act(() => { jest.runAllTimers(); }); + expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('should not call onDismiss when dismissable is false', async () => { + it('does not invoke onDismiss for a non-dismissible dialog when the backdrop is pressed', async () => { const onDismiss = jest.fn(); + await render( This is simple dialog ); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); }); + expect(onDismiss).toHaveBeenCalledTimes(0); }); - it('should call onDismiss on Android back button when dismissable is false but dismissableBackButton is true', async () => { - Platform.OS = 'android'; + it('invokes onDismiss on Android back button press when only dismissableBackButton is true', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + const onDismiss = jest.fn(); + await render( This is simple dialog ); - await userEvent.press(screen.getByLabelText('Close modal')); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); }); + expect(onDismiss).toHaveBeenCalledTimes(0); await act(() => { BackHandler.mockPressBack(); jest.runAllTimers(); }); + expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('should apply top margin to the first child if the dialog is V3', async () => { + it('applies top margin to the first child', async () => { await render( - + Test Dialog Content @@ -123,10 +139,41 @@ describe('Dialog', () => { marginTop: 24, }); }); + + it('uses the title as the accessible name on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + + await render( + + + Alert + This is simple dialog + + + ); + + expect(screen.getByLabelText('Alert')).toHaveProp('role', 'dialog'); + }); + + it('uses aria-label over the title as the accessible name', async () => { + await render( + + + Alert + + + ); + + expect(screen.getByLabelText('Confirm deletion')).toHaveProp( + 'role', + 'dialog' + ); + expect(screen.queryByLabelText('Alert')).not.toBeOnTheScreen(); + }); }); describe('DialogActions', () => { - it('should render passed children', async () => { + it('renders passed children', async () => { await render( @@ -138,7 +185,7 @@ describe('DialogActions', () => { expect(screen.getByTestId('button-ok')).toBeOnTheScreen(); }); - it('should apply default styles', async () => { + it('applies default styles', async () => { await render( @@ -157,7 +204,7 @@ describe('DialogActions', () => { expect(dialogActionButtons[1]).toHaveStyle({ marginRight: 0 }); }); - it('should apply custom styles', async () => { + it('applies custom styles', async () => { await render( diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 27a75475ec..6a071893c2 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -40,626 +40,744 @@ describe('Modal', () => { /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ }); - describe('by default', () => { - it('should render passed children', async () => { - await render( - - - Children - - - ); - - expect(screen.getByTestId('modal')).toHaveTextContent('Children'); - }); - - it("should render a backdrop in default theme's color", async () => { - await render( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - backgroundColor: LightTheme.colors.scrim, - }); - }); - - it('should render a custom backdrop color if specified', async () => { - await render( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - backgroundColor: 'transparent', - }); - }); - - it('should receive appropriate top and bottom insets', async () => { - const { toJSON } = await render( - - - {null} - - - ); - - expect(toJSON()).toMatchSnapshot(); + it('renders passed children', async () => { + await render( + + + Children + + + ); + + expect(screen.getByTestId('modal')).toHaveTextContent('Children'); + }); + + it("renders a backdrop in default theme's color", async () => { + await render( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + backgroundColor: LightTheme.colors.scrim, + }); + }); + + it('renders a custom backdrop color if specified', async () => { + await render( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + backgroundColor: 'transparent', }); }); - describe('when open', () => { - describe('if backdrop touched', () => { - it('should invoke the onDismiss function immediately', async () => { - const onDismiss = jest.fn(); - const { toJSON } = await render( - - - {null} - - - ); - expect(onDismiss).not.toHaveBeenCalled(); + it('receives appropriate top and bottom insets', async () => { + const { toJSON } = await render( + + + {null} + + + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('exposes the modal as a dialog with an accessible name', async () => { + await render( + + + Modal content + + + ); + + // Role queries only match accessibility elements, and the dialog + // container must not be one or it would swallow its children on iOS. + expect(screen.getByLabelText('Example modal')).toHaveProp('role', 'dialog'); + }); + + it('hides the backdrop from assistive technology', async () => { + await render( + + + {null} + + + ); + + expect(screen.queryByTestId('backdrop')).not.toBeOnTheScreen(); + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toBeOnTheScreen(); + }); + + it('invokes onDismiss immediately when the backdrop is pressed', async () => { + const onDismiss = jest.fn(); - await userEvent.press(screen.getByLabelText('Close modal')); + const { toJSON } = await render( + + + {null} + + + ); - expect(onDismiss).toHaveBeenCalled(); + expect(onDismiss).not.toHaveBeenCalled(); - expect(toJSON()).toMatchSnapshot(); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); - await act(() => { - jest.runAllTimers(); - }); + expect(onDismiss).toHaveBeenCalled(); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); + expect(toJSON()).toMatchSnapshot(); - expect(toJSON()).toMatchSnapshot(); + await act(() => { + jest.runAllTimers(); + }); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, }); - it('runs the closing animation if visible toggled', async () => { - const { rerender, toJSON } = await render( - - {}}> - {null} - - - ); + expect(toJSON()).toMatchSnapshot(); - expect(toJSON()).toMatchSnapshot(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); - await userEvent.press(screen.getByLabelText('Close modal')); + it('runs the closing animation when visible changes to false after dismissing', async () => { + const { rerender, toJSON } = await render( + + {}} + > + {null} + + + ); + + expect(toJSON()).toMatchSnapshot(); + + await userEvent.press(screen.getByRole('button', { name: 'Close modal' })); + + await rerender( + + {}} + > + {null} + + + ); + + expect(toJSON()).toMatchSnapshot(); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); - await rerender( - - {}}> - {null} - - - ); + expect(toJSON()).toMatchSnapshot(); - expect(toJSON()).toMatchSnapshot(); + await act(() => { + jest.runAllTimers(); + }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + }); - expect(toJSON()).toMatchSnapshot(); + it('invokes onDismiss when the visually hidden dismiss button is pressed', async () => { + const onDismiss = jest.fn(); + + await render( + + + {null} + + + ); + + await userEvent.press( + screen.getByRole('button', { name: 'Dismiss dialog' }) + ); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); - await act(() => { - jest.runAllTimers(); - }); + it('invokes onDismiss on the accessibility escape gesture', async () => { + const onDismiss = jest.fn(); + + await render( + + + {null} + + + ); + + await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); - expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + it('invokes onDismiss when the Android back button is pressed', async () => { + const onDismiss = jest.fn(); + + const { toJSON } = await render( + + + {null} + + + ); + + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + BackHandler.mockPressBack(); }); - describe('if closed via the accessibility escape gesture', () => { - it('should invoke the onDismiss function', async () => { - const onDismiss = jest.fn(); + expect(toJSON()).toMatchSnapshot(); - await render( - - - {null} - - - ); + await act(() => { + jest.runAllTimers(); + }); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); - await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); + expect(toJSON()).toMatchSnapshot(); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('does not invoke onDismiss on Android back button press when dismissableBackButton is false', async () => { + const onDismiss = jest.fn(); + + await render( + + + {null} + + + ); + + await act(() => { + BackHandler.mockPressBack(); + jest.runAllTimers(); }); - describe('if closed via Android back button', () => { - it('invokes onDismiss', async () => { - const onDismiss = jest.fn(); - const { toJSON } = await render( - - - {null} - - - ); + expect(onDismiss).not.toHaveBeenCalled(); - expect(toJSON()).toMatchSnapshot(); + await userEvent.press(screen.getByRole('button', { name: 'Close modal' })); - await act(() => { - BackHandler.mockPressBack(); - }); + await act(() => { + jest.runAllTimers(); + }); - expect(toJSON()).toMatchSnapshot(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); - await act(() => { - jest.runAllTimers(); - }); + it('does not render the visually hidden dismiss button for a non-dismissible modal', async () => { + await render( + + {}}> + {null} + + + ); + + expect( + screen.queryByRole('button', { name: 'Close modal' }) + ).not.toBeOnTheScreen(); + }); - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); + it('keeps a non-dismissible modal visible when the backdrop is pressed', async () => { + const { toJSON } = await render( + + {}} + dismissable={false} + > + {null} + + + ); + + expect(toJSON()).toMatchSnapshot(); + + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); + + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + jest.runAllTimers(); + }); - expect(toJSON()).toMatchSnapshot(); + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + + expect(toJSON()).toMatchSnapshot(); + }); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); + it('does not invoke onDismiss for a non-dismissible modal when the backdrop is pressed', async () => { + const onDismiss = jest.fn(); + + await render( + + + {null} + + + ); + + expect(onDismiss).not.toHaveBeenCalled(); + + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); + + expect(onDismiss).not.toHaveBeenCalled(); + + await act(() => { + jest.runAllTimers(); }); + + expect(onDismiss).not.toHaveBeenCalled(); }); - describe('when open as non-dismissible modal', () => { - describe('if closed via touching backdrop', () => { - it('will run the animation but not fade out', async () => { - const { toJSON } = await render( - - {}} - dismissable={false} - > - {null} - - - ); - - expect(toJSON()).toMatchSnapshot(); - - await userEvent.press(screen.getByLabelText('Close modal')); - - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - - expect(toJSON()).toMatchSnapshot(); - }); - - it('should not invoke onDismiss on back press when only dismissableBackButton is false', async () => { - const onDismiss = jest.fn(); - - await render( - - - {null} - - - ); - - await act(() => { - BackHandler.mockPressBack(); - jest.runAllTimers(); - }); - - expect(onDismiss).not.toHaveBeenCalled(); - - // Pressing outside still dismisses it -- only the back button is opted out. - await userEvent.press(screen.getByLabelText('Close modal')); - - await act(() => { - jest.runAllTimers(); - }); - - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it('should not invoke onDismiss', async () => { - const onDismiss = jest.fn(); - await render( - - - {null} - - - ); - - expect(onDismiss).not.toHaveBeenCalled(); - - await userEvent.press(screen.getByLabelText('Close modal')); - - expect(onDismiss).not.toHaveBeenCalled(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(onDismiss).not.toHaveBeenCalled(); - }); - }); - - describe('if closed via the accessibility escape gesture', () => { - it('should keep the modal on screen', async () => { - const onDismiss = jest.fn(); - - await render( - - - {null} - - - ); - - await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); - - expect(onDismiss).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Close modal')).toBeOnTheScreen(); - }); - }); - - describe('if closed via Android back button', () => { - it('will run the animation but not fade out', async () => { - const { toJSON } = await render( - - {}} - dismissable={false} - > - {null} - - - ); - - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - BackHandler.mockPressBack(); - }); - - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - - expect(toJSON()).toMatchSnapshot(); - }); - - it('should not invoke onDismiss', async () => { - const onDismiss = jest.fn(); - - await render( - - - {null} - - - ); - - expect(onDismiss).not.toHaveBeenCalled(); - - await act(() => { - BackHandler.mockPressBack(); - }); - - expect(onDismiss).not.toHaveBeenCalled(); - - await act(() => { - jest.runAllTimers(); - }); + it('keeps a non-dismissible modal on screen on the accessibility escape gesture', async () => { + const onDismiss = jest.fn(); + + await render( + + + {null} + + + ); + + await fireEvent(screen.getByTestId('modal'), 'accessibilityEscape'); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(screen.getByTestId('modal')).toBeOnTheScreen(); + }); - expect(onDismiss).not.toHaveBeenCalled(); - }); + it('keeps a non-dismissible modal visible when the Android back button is pressed', async () => { + const { toJSON } = await render( + + {}} + dismissable={false} + > + {null} + + + ); + + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + BackHandler.mockPressBack(); }); + + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + jest.runAllTimers(); + }); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + + expect(toJSON()).toMatchSnapshot(); }); - describe('when visible prop changes', () => { - describe('from false to true (closed to open)', () => { - it('should run fade-in animation on opening', async () => { - const { rerender, toJSON } = await render( - - - {null} - - - ); - - expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); - - await rerender( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: 0, - }); - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - }); - }); - - describe('from true to false (open to closed)', () => { - it('should run fade-out animation on closing', async () => { - const { rerender, toJSON } = await render( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - - await rerender( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); - }); - - it('should not invoke onDismiss', async () => { - const onDismiss = jest.fn(); - - const { rerender } = await render( - - - {null} - - - ); - - expect(onDismiss).not.toHaveBeenCalled(); - - await rerender( - - - {null} - - - ); - - expect(onDismiss).not.toHaveBeenCalled(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(onDismiss).not.toHaveBeenCalled(); - }); - - it('should close even if the dialog is not dismissible', async () => { - const { rerender, toJSON } = await render( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - - await rerender( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); - }); + it('does not invoke onDismiss for a non-dismissible modal when the Android back button is pressed', async () => { + const onDismiss = jest.fn(); + + await render( + + + {null} + + + ); + + expect(onDismiss).not.toHaveBeenCalled(); + + await act(() => { + BackHandler.mockPressBack(); }); + + expect(onDismiss).not.toHaveBeenCalled(); + + await act(() => { + jest.runAllTimers(); + }); + + expect(onDismiss).not.toHaveBeenCalled(); }); - describe('when visible prop changes again during the open/close animation', () => { - describe('while closing, back to true (visible)', () => { - it('should keep the modal open', async () => { - const { rerender, toJSON } = await render( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - - await rerender( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - // Not a real seconds, this depends on how frequently - // requestAnimationFrame is called - jest.advanceTimersToNextTimer(1000); - }); - - await rerender( - - - {null} - - - ); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: scrimAlpha, - }); - expect(toJSON()).toMatchSnapshot(); - }); - }); - - describe('while opening, back to false (hidden)', () => { - it('should keep the modal closed', async () => { - const { rerender, toJSON } = await render( - - - {null} - - - ); - - expect(screen.queryByLabelText('Close modal')).not.toBeOnTheScreen(); - - await rerender( - - - {null} - - - ); - - expect(screen.getByLabelText('Close modal')).toHaveStyle({ - opacity: 0, - }); - expect(toJSON()).toMatchSnapshot(); - - await act(() => { - // Not a real seconds, this depends on how frequently - // requestAnimationFrame is called - jest.advanceTimersToNextTimer(1000); - }); - - expect(screen.getByLabelText('Close modal')).toBeOnTheScreen(); - - await rerender( - - - {null} - - - ); - - await act(() => { - jest.runAllTimers(); - }); - - expect(screen.queryByLabelText('Close modal')).not.toBeOnTheScreen(); - }); + it('runs the fade-in animation when visible changes from false to true', async () => { + const { rerender, toJSON } = await render( + + + {null} + + + ); + + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + + await rerender( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: 0, }); + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + jest.runAllTimers(); + }); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); }); -}); -it('exposes the modal as a dialog with an accessible name', async () => { - await render( - - {}} aria-label="Example modal"> - Modal content - - - ); + it('runs the fade-out animation when visible changes from true to false', async () => { + const { rerender, toJSON } = await render( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); + + await rerender( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + jest.runAllTimers(); + }); + + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + }); + + it('does not invoke onDismiss when visible changes from true to false', async () => { + const onDismiss = jest.fn(); + + const { rerender } = await render( + + + {null} + + + ); + + expect(onDismiss).not.toHaveBeenCalled(); + + await rerender( + + + {null} + + + ); + + expect(onDismiss).not.toHaveBeenCalled(); + + await act(() => { + jest.runAllTimers(); + }); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('closes a non-dismissible modal when visible changes from true to false', async () => { + const { rerender, toJSON } = await render( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); + + await rerender( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + jest.runAllTimers(); + }); + + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + }); + + it('keeps the modal open when visible changes back to true while closing', async () => { + const { rerender, toJSON } = await render( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); + + await rerender( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + // Not a real seconds, this depends on how frequently + // requestAnimationFrame is called + jest.advanceTimersToNextTimer(1000); + }); - expect(screen.getByLabelText('Example modal')).toHaveProp('role', 'dialog'); + await rerender( + + + {null} + + + ); + + await act(() => { + jest.runAllTimers(); + }); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: scrimAlpha, + }); + expect(toJSON()).toMatchSnapshot(); + }); + + it('keeps the modal closed when visible changes back to false while opening', async () => { + const { rerender, toJSON } = await render( + + + {null} + + + ); + + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + + await rerender( + + + {null} + + + ); + + expect( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ).toHaveStyle({ + opacity: 0, + }); + expect(toJSON()).toMatchSnapshot(); + + await act(() => { + // Not a real seconds, this depends on how frequently + // requestAnimationFrame is called + jest.advanceTimersToNextTimer(1000); + }); + + expect(screen.getByTestId('modal')).toBeOnTheScreen(); + + await rerender( + + + {null} + + + ); + + await act(() => { + jest.runAllTimers(); + }); + + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + }); }); diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index 8fd0d7dc04..8cf23e1346 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Modal by default should receive appropriate top and bottom insets 1`] = ` +exports[`Modal closes a non-dismissible modal when visible changes from true to false 1`] = ` <> `; -exports[`Modal when open as non-dismissible modal if closed via Android back button will run the animation but not fade out 1`] = ` +exports[`Modal closes a non-dismissible modal when visible changes from true to false 2`] = ` <> `; -exports[`Modal when open as non-dismissible modal if closed via Android back button will run the animation but not fade out 2`] = ` +exports[`Modal invokes onDismiss immediately when the backdrop is pressed 1`] = ` <> + @@ -628,7 +669,7 @@ exports[`Modal when open as non-dismissible modal if closed via Android back but `; -exports[`Modal when open as non-dismissible modal if closed via Android back button will run the animation but not fade out 3`] = ` +exports[`Modal invokes onDismiss immediately when the backdrop is pressed 2`] = ` <> + @@ -837,7 +920,7 @@ exports[`Modal when open as non-dismissible modal if closed via Android back but `; -exports[`Modal when open as non-dismissible modal if closed via touching backdrop will run the animation but not fade out 1`] = ` +exports[`Modal invokes onDismiss when the Android back button is pressed 1`] = ` <> + @@ -1046,7 +1171,7 @@ exports[`Modal when open as non-dismissible modal if closed via touching backdro `; -exports[`Modal when open as non-dismissible modal if closed via touching backdrop will run the animation but not fade out 2`] = ` +exports[`Modal invokes onDismiss when the Android back button is pressed 2`] = ` <> + @@ -1255,7 +1422,7 @@ exports[`Modal when open as non-dismissible modal if closed via touching backdro `; -exports[`Modal when open as non-dismissible modal if closed via touching backdrop will run the animation but not fade out 3`] = ` +exports[`Modal invokes onDismiss when the Android back button is pressed 3`] = ` <> + @@ -1464,7 +1673,7 @@ exports[`Modal when open as non-dismissible modal if closed via touching backdro `; -exports[`Modal when open if backdrop touched should invoke the onDismiss function immediately 1`] = ` +exports[`Modal keeps a non-dismissible modal visible when the Android back button is pressed 1`] = ` <> `; -exports[`Modal when open if backdrop touched should invoke the onDismiss function immediately 2`] = ` +exports[`Modal keeps a non-dismissible modal visible when the Android back button is pressed 2`] = ` <> `; -exports[`Modal when open if closed via Android back button invokes onDismiss 1`] = ` +exports[`Modal keeps a non-dismissible modal visible when the Android back button is pressed 3`] = ` <> `; -exports[`Modal when open if closed via Android back button invokes onDismiss 2`] = ` +exports[`Modal keeps a non-dismissible modal visible when the backdrop is pressed 1`] = ` <> `; -exports[`Modal when open if closed via Android back button invokes onDismiss 3`] = ` +exports[`Modal keeps a non-dismissible modal visible when the backdrop is pressed 2`] = ` <> `; -exports[`Modal when open runs the closing animation if visible toggled 1`] = ` +exports[`Modal keeps a non-dismissible modal visible when the backdrop is pressed 3`] = ` <> `; -exports[`Modal when open runs the closing animation if visible toggled 2`] = ` +exports[`Modal keeps the modal closed when visible changes back to false while opening 1`] = ` <> + @@ -2934,7 +3178,7 @@ exports[`Modal when open runs the closing animation if visible toggled 2`] = ` `; -exports[`Modal when open runs the closing animation if visible toggled 3`] = ` +exports[`Modal keeps the modal open when visible changes back to true while closing 1`] = ` <> + @@ -3144,7 +3429,7 @@ exports[`Modal when open runs the closing animation if visible toggled 3`] = ` `; -exports[`Modal when visible prop changes again during the open/close animation while closing, back to true (visible) should keep the modal open 1`] = ` +exports[`Modal keeps the modal open when visible changes back to true while closing 2`] = ` <> + @@ -3354,7 +3680,7 @@ exports[`Modal when visible prop changes again during the open/close animation w `; -exports[`Modal when visible prop changes again during the open/close animation while closing, back to true (visible) should keep the modal open 2`] = ` +exports[`Modal keeps the modal open when visible changes back to true while closing 3`] = ` <> + @@ -3564,7 +3931,7 @@ exports[`Modal when visible prop changes again during the open/close animation w `; -exports[`Modal when visible prop changes again during the open/close animation while closing, back to true (visible) should keep the modal open 3`] = ` +exports[`Modal receives appropriate top and bottom insets 1`] = ` <> + @@ -3774,7 +4182,7 @@ exports[`Modal when visible prop changes again during the open/close animation w `; -exports[`Modal when visible prop changes again during the open/close animation while opening, back to false (hidden) should keep the modal closed 1`] = ` +exports[`Modal runs the closing animation when visible changes to false after dismissing 1`] = ` <> + @@ -3984,7 +4433,7 @@ exports[`Modal when visible prop changes again during the open/close animation w `; -exports[`Modal when visible prop changes from false to true (closed to open) should run fade-in animation on opening 1`] = ` +exports[`Modal runs the closing animation when visible changes to false after dismissing 2`] = ` <> + @@ -4194,7 +4684,7 @@ exports[`Modal when visible prop changes from false to true (closed to open) sho `; -exports[`Modal when visible prop changes from false to true (closed to open) should run fade-in animation on opening 2`] = ` +exports[`Modal runs the closing animation when visible changes to false after dismissing 3`] = ` <> + @@ -4404,7 +4935,7 @@ exports[`Modal when visible prop changes from false to true (closed to open) sho `; -exports[`Modal when visible prop changes from true to false (open to closed) should close even if the dialog is not dismissible 1`] = ` +exports[`Modal runs the fade-in animation when visible changes from false to true 1`] = ` <> + @@ -4613,7 +5186,7 @@ exports[`Modal when visible prop changes from true to false (open to closed) sho `; -exports[`Modal when visible prop changes from true to false (open to closed) should close even if the dialog is not dismissible 2`] = ` +exports[`Modal runs the fade-in animation when visible changes from false to true 2`] = ` <> + @@ -4822,7 +5437,7 @@ exports[`Modal when visible prop changes from true to false (open to closed) sho `; -exports[`Modal when visible prop changes from true to false (open to closed) should run fade-out animation on closing 1`] = ` +exports[`Modal runs the fade-out animation when visible changes from true to false 1`] = ` <> + @@ -5032,7 +5688,7 @@ exports[`Modal when visible prop changes from true to false (open to closed) sho `; -exports[`Modal when visible prop changes from true to false (open to closed) should run fade-out animation on closing 2`] = ` +exports[`Modal runs the fade-out animation when visible changes from true to false 2`] = ` <> +