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..f24f0e986b 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, @@ -182,7 +188,7 @@ function Modal({ } const onHardwareBackPress = () => { - if (dismissable || dismissableBackButton) { + if (dismissableBackButton) { onDismissCallback(); } @@ -196,7 +202,7 @@ function Modal({ ); return () => subscription.remove(); - }, [dismissable, dismissableBackButton, onDismissCallback, visible]); + }, [dismissableBackButton, onDismissCallback, visible]); const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); @@ -227,10 +233,9 @@ function Modal({ return ( { 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(); @@ -219,6 +235,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( @@ -246,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( @@ -523,3 +592,13 @@ describe('Modal', () => { }); }); }); + +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..5ba0c2a9dc 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`] = `