Skip to content
Open
14 changes: 14 additions & 0 deletions example/src/Examples/DialogExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DialogWithLoadingIndicator,
DialogWithLongText,
DialogWithRadioBtns,
DialogWithUndismissableBackButton,
UndismissableDialog,
} from './Dialogs';
import ScreenWrapper from '../ScreenWrapper';
Expand Down Expand Up @@ -79,6 +80,15 @@ const DialogExample = () => {
Dismissable back button
</Button>
)}
{Platform.OS === 'android' && (
<Button
mode="outlined"
onPress={_toggleDialog('dialog8')}
style={styles.button}
>
Undismissable back button
</Button>
)}
<DialogWithLongText
visible={_getVisible('dialog1')}
close={_toggleDialog('dialog1')}
Expand Down Expand Up @@ -107,6 +117,10 @@ const DialogExample = () => {
visible={_getVisible('dialog7')}
close={_toggleDialog('dialog7')}
/>
<DialogWithUndismissableBackButton
visible={_getVisible('dialog8')}
close={_toggleDialog('dialog8')}
/>
</ScreenWrapper>
);
};
Expand Down
31 changes: 31 additions & 0 deletions example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<Portal>
<Dialog onDismiss={close} visible={visible} dismissableBackButton={false}>
<Dialog.Title>Alert</Dialog.Title>
<Dialog.Content>
<TextComponent>
This dialog can be dismissed by tapping outside, however the hardware
back button will not close it!
</TextComponent>
</Dialog.Content>
<Dialog.Actions>
<Button textColor={Palette.tertiary50} disabled>
Disagree
</Button>
<Button onPress={close}>Agree</Button>
</Dialog.Actions>
</Dialog>
</Portal>
);

export default DialogWithUndismissableBackButton;
1 change: 1 addition & 0 deletions example/src/Examples/Dialogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
21 changes: 15 additions & 6 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with overlayAccessibilityLabel removed, how can user customize aria label for the overlay or access it in tests (there is test id, but better practice is to use a11y, same way your users will)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@satya164 please check my comment above. It will not be needed if we move forward with not making it accessible

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored the label

/**
* Accessible name for the modal.
*/
'aria-label'?: string;
/**
* testID for the overlay that is displayed behind the modal content.
*/
Expand Down Expand Up @@ -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
Expand All @@ -110,6 +114,7 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
* <Modal
* visible={visible}
* onDismiss={hideModal}
* aria-label="Example modal"
* contentBackgroundColor="white"
* contentContainerStyle={containerStyle}
* >
Expand All @@ -131,6 +136,7 @@ function Modal({
dismissableBackButton = dismissable,
visible = false,
overlayAccessibilityLabel = 'Close modal',
'aria-label': ariaLabel,
overlayTestID,
onDismiss = () => {},
children,
Expand Down Expand Up @@ -182,7 +188,7 @@ function Modal({
}

const onHardwareBackPress = () => {
if (dismissable || dismissableBackButton) {
if (dismissableBackButton) {
onDismissCallback();
}

Expand All @@ -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);

Expand Down Expand Up @@ -227,20 +233,20 @@ function Modal({
return (
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
aria-modal
aria-live="polite"
style={StyleSheet.absoluteFill}
onAccessibilityEscape={onDismissCallback}
onAccessibilityEscape={dismissable ? onDismissCallback : undefined}

@k0ndee k0ndee Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modal.tsx has missing focus-related code that is one of the acceptance criteria for this ticket.

  • Initial focus - nothing moves focus into the Surface when visible becomes true
  • Focus trap - nothing intercepts Tab to keep focus inside the modal while open; a keyboard or switch-control user can tab straight into the content behind it
  • Focus restore - nothing captures the pre-open focus target or restores it in onDismissCallback on close

Given the ticket calls this out as the hard, VoiceOver/TalkBack/keyboard/switch-control-verified part of the work, will there be another follow-up PR for this, or should it be included here? @satya164 what do You think about it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be a follow-up PR with initial focus / restore focus.
Regarding focus trap - #5126 this one covers it.

testID={testID}
>
<AnimatedPressable
aria-label={overlayAccessibilityLabel}
role="button"
disabled={!dismissable}
onPress={dismissable ? onDismissCallback : undefined}
importantForAccessibility="no"
style={[styles.backdrop, backdropStyle, backdropTransitionStyle]}
testID={overlayTestID}
importantForAccessibility={dismissable ? 'auto' : 'no'}
accessible={dismissable}
/>
<View
style={[
Expand All @@ -251,6 +257,9 @@ function Modal({
pointerEvents="box-none"
>
<Surface
role="dialog"
aria-modal
aria-label={ariaLabel}
theme={theme}
backgroundColor={contentBackgroundColor}
borderRadius={contentBorderRadius}
Expand Down
81 changes: 80 additions & 1 deletion src/components/__tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BackHandler as RNBackHandler, Text } from 'react-native';
import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native';

import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals';
import { act, userEvent } from '@testing-library/react-native';
import { act, fireEvent, userEvent } from '@testing-library/react-native';

import { render, screen } from '../../test-utils';
import { LightTheme } from '../../theme/schemes';
Expand Down Expand Up @@ -156,6 +156,22 @@ describe('Modal', () => {
expect(toJSON()).toBeNull();
});

describe('if closed via the accessibility escape gesture', () => {
it('should invoke the onDismiss function', async () => {
const onDismiss = jest.fn();

await render(
<Modal testID="modal" visible onDismiss={onDismiss}>
{null}
</Modal>
);

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();
Expand Down Expand Up @@ -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(
<Modal
testID="modal"
visible
onDismiss={onDismiss}
dismissableBackButton={false}
>
{null}
</Modal>
);

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(
Expand Down Expand Up @@ -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(
<Modal
testID="modal"
visible
onDismiss={onDismiss}
dismissable={false}
>
{null}
</Modal>
);

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(
Expand Down Expand Up @@ -523,3 +592,13 @@ describe('Modal', () => {
});
});
});

it('exposes the modal as a dialog with an accessible name', async () => {
await render(
<Modal visible onDismiss={() => {}} aria-label="Example modal">
<Text>Modal content</Text>
</Modal>
);

expect(screen.getByLabelText('Example modal')).toHaveProp('role', 'dialog');
});
Loading
Loading