Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
30 changes: 18 additions & 12 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -33,9 +33,13 @@ export type Props = {
*/
onDismiss?: () => void;
/**
* Accessibility label for the overlay. This is read by the screen reader when the user taps outside the modal.
* Accessible name for 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

'aria-label'?: string;
/**
* Role exposed to assistive technology. Defaults to `dialog`.
*/
role?: Role;
/**
* 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 @@ -130,7 +135,8 @@ function Modal({
dismissable = true,
dismissableBackButton = dismissable,
visible = false,
overlayAccessibilityLabel = 'Close modal',
'aria-label': ariaLabel,
role = 'dialog',
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,18 +233,15 @@ 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}
aria-hidden

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.

Why is overlay hidden from a11y with aria-hidden?

If a dialog doesn't have a button to close itself, doesn't mean screen reader users and keyboard users won't be able to close it at all as compared to mouse users?

@konstmar konstmar Sep 17, 2026 •

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 I made the decision to not make it accessible based on a few things:

  1. It was not accessible on android in the first place (via importantForAccessibility)
  2. The task suggested removing the label since it was not accessible
  3. https://m3.material.io/components/dialogs/guidelines 's anatomy shows that Dialog should have actions.
  4. https://m3.material.io/components/dialogs/accessibility says the initial focus should land on the first interactive element within dialog, not on scrim. And the scrim is just a visual dimmer.
  5. https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ :
    • "All elements required to operate the dialog are descendants of the element that has role dialog."
    • "Tab and Shift + Tab do not move focus outside the dialog"
    • "It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button."

The screen reader users and keyboard users can still use Escape/Back to initiate close.

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.

Dialog should have actions.

It should, but material design guidelines are still just guidelines. They are not requirements the users must implement. Also this affects modals in general, not just dialogs.

says the initial focus should land on the first interactive element within dialog, not on scrim.

What's scrim? Overlay? That sounds right, but it could be achieved without making the overlay inaccessible.

And the scrim is just a visual dimmer.

But the overlay is not just a visual dimmer here, users can already interact with it using a mouse, which makes it an interactive element.

Every interactive element should have equivalent for screen readers. If it's not the overlay itself, then there needs to be a screen reader specific affordance to close the modal like a mouse user would.

"All elements required to operate the dialog are descendants of the element that has role dialog."
"It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button."

Still, the problem is these are guidelines and recommendations. While we can implement the recommendations in the library itself, the user may not. We should still provide an accessible experience by default.

Prior design to this would be what React Aria does. It hides the overlay from a11y, but also provides a screen-reader only affordance.

Following is from Codex:

When outside-click dismissal is enabled, React Aria automatically adds a visually hidden dismiss button inside the modal. Screen reader users can find and activate it even if the app doesn't provide a close button. It has a localized label and tabIndex={-1}, so it doesn't add an extra Tab stop. Keyboard users can dismiss with Escape. (Modal implementation, dismiss button)
We could follow the same approach. If we hide the overlay from a11y, we should provide an equivalent dismiss button for screen reader users whenever clicking the overlay can close the modal. We also need Escape handling on web, which is currently missing.

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 Got it. I'll revert the changes and make overlay accessible again when modal is dismissable.

onPress={dismissable ? onDismissCallback : undefined}
importantForAccessibility="no"
style={[styles.backdrop, backdropStyle, backdropTransitionStyle]}
testID={overlayTestID}
/>
Expand All @@ -251,6 +254,9 @@ function Modal({
pointerEvents="box-none"
>
<Surface
role={role}
aria-modal
aria-label={ariaLabel}
theme={theme}
backgroundColor={contentBackgroundColor}
borderRadius={contentBorderRadius}
Expand Down
31 changes: 25 additions & 6 deletions src/components/__tests__/Dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const BackHandler = RNBackHandler as BackHandlerStatic;
describe('Dialog', () => {
it('should render passed children', async () => {
await render(
<Dialog visible testID="dialog">
<Dialog visible testID="dialog" overlayTestID="backdrop">
<Text>This is simple dialog</Text>
</Dialog>
);
Expand All @@ -36,12 +36,20 @@ describe('Dialog', () => {
it('should call onDismiss when dismissable', async () => {
const onDismiss = jest.fn();
await render(
<Dialog visible onDismiss={onDismiss} dismissable testID="dialog">
<Dialog
visible
onDismiss={onDismiss}
dismissable
testID="dialog"
overlayTestID="backdrop"
>
<Text>This is simple dialog</Text>
</Dialog>
);

await userEvent.press(screen.getByLabelText('Close modal'));
await userEvent.press(
screen.getByTestId('backdrop', { includeHiddenElements: true })
);

await act(() => {
jest.runAllTimers();
Expand All @@ -52,12 +60,20 @@ describe('Dialog', () => {
it('should not call onDismiss when dismissable is false', async () => {
const onDismiss = jest.fn();
await render(
<Dialog visible onDismiss={onDismiss} dismissable={false} testID="dialog">
<Dialog
visible
onDismiss={onDismiss}
dismissable={false}
testID="dialog"
overlayTestID="backdrop"
>
<Text>This is simple dialog</Text>
</Dialog>
);

await userEvent.press(screen.getByLabelText('Close modal'));
await userEvent.press(
screen.getByTestId('backdrop', { includeHiddenElements: true })
);

await act(() => {
jest.runAllTimers();
Expand All @@ -75,12 +91,15 @@ describe('Dialog', () => {
dismissable={false}
dismissableBackButton
testID="dialog"
overlayTestID="backdrop"
>
<Text>This is simple dialog</Text>
</Dialog>
);

await userEvent.press(screen.getByLabelText('Close modal'));
await userEvent.press(
screen.getByTestId('backdrop', { includeHiddenElements: true })
);

await act(() => {
jest.runAllTimers();
Expand Down
Loading