From 6952ce08049d52732f49a0cc3b3ec16aa8fa6285 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 14 Sep 2026 18:22:53 +0200 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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" >