Skip to content

Commit 4201235

Browse files
konstmarKonstantin Marushchaksatya164
authored
fix(modal): make Modal accessible (#5125)
Co-authored-by: Konstantin Marushchak <konstantin.maruschak@callstack.com> Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
1 parent 179437e commit 4201235

12 files changed

Lines changed: 1852 additions & 724 deletions

File tree

‎docs/6.x/docs/guides/migration.md‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,35 @@ e.g.:
195195
</Modal>
196196
```
197197

198+
The modal content now has the `dialog` role, so it needs an accessible name. You can provide one with the new `aria-label` prop:
199+
200+
```diff
201+
-<Modal visible={visible} onDismiss={hideModal}>
202+
+<Modal visible={visible} onDismiss={hideModal} aria-label="Example modal">
203+
<Text>Content</Text>
204+
</Modal>
205+
```
206+
207+
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.
208+
209+
The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`, which is used for the button's accessibility label:
210+
211+
```diff
212+
<Modal
213+
visible={visible}
214+
onDismiss={hideModal}
215+
- overlayAccessibilityLabel="Close"
216+
+ dismissAccessibilityLabel="Close"
217+
>
218+
<Text>Content</Text>
219+
</Modal>
220+
```
221+
222+
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.
223+
198224
### Dialog
199225

200-
`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`:
226+
`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`:
201227

202228
```diff
203229
-<Portal>
@@ -210,9 +236,37 @@ e.g.:
210236
+</Dialog>
211237
```
212238

239+
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:
240+
241+
```jsx
242+
<Dialog visible={visible} onDismiss={hideDialog} aria-label="Delete file">
243+
<Dialog.Content>
244+
<Text>Are you sure?</Text>
245+
</Dialog.Content>
246+
</Dialog>
247+
```
248+
249+
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.
250+
213251
- The default elevation changed from level `1` to level `3`.
214252
- 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.
215253
254+
### Menu
255+
256+
The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`:
257+
258+
```diff
259+
<Menu
260+
visible={visible}
261+
onDismiss={closeMenu}
262+
anchor={anchor}
263+
- overlayAccessibilityLabel="Close"
264+
+ dismissAccessibilityLabel="Close"
265+
>
266+
<Menu.Item title="Item" />
267+
</Menu>
268+
```
269+
216270
### Searchbar
217271
218272
The misspelled `traileringIcon` props have been renamed:

‎example/src/Examples/DialogExample.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DialogWithLoadingIndicator,
1111
DialogWithLongText,
1212
DialogWithRadioBtns,
13+
DialogWithUndismissableBackButton,
1314
UndismissableDialog,
1415
} from './Dialogs';
1516
import ScreenWrapper from '../ScreenWrapper';
@@ -79,6 +80,15 @@ const DialogExample = () => {
7980
Dismissable back button
8081
</Button>
8182
)}
83+
{Platform.OS === 'android' && (
84+
<Button
85+
mode="outlined"
86+
onPress={_toggleDialog('dialog8')}
87+
style={styles.button}
88+
>
89+
Undismissable back button
90+
</Button>
91+
)}
8292
<DialogWithLongText
8393
visible={_getVisible('dialog1')}
8494
close={_toggleDialog('dialog1')}
@@ -107,6 +117,10 @@ const DialogExample = () => {
107117
visible={_getVisible('dialog7')}
108118
close={_toggleDialog('dialog7')}
109119
/>
120+
<DialogWithUndismissableBackButton
121+
visible={_getVisible('dialog8')}
122+
close={_toggleDialog('dialog8')}
123+
/>
110124
</ScreenWrapper>
111125
);
112126
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Button, Dialog, Palette } from 'react-native-paper';
2+
3+
import { TextComponent } from './DialogTextComponent';
4+
5+
const DialogWithUndismissableBackButton = ({
6+
visible,
7+
close,
8+
}: {
9+
visible: boolean;
10+
close: () => void;
11+
}) => (
12+
<Dialog onDismiss={close} visible={visible} dismissableBackButton={false}>
13+
<Dialog.Title>Alert</Dialog.Title>
14+
<Dialog.Content>
15+
<TextComponent>
16+
This dialog can be dismissed by tapping outside, however the hardware
17+
back button will not close it!
18+
</TextComponent>
19+
</Dialog.Content>
20+
<Dialog.Actions>
21+
<Button textColor={Palette.tertiary50} disabled>
22+
Disagree
23+
</Button>
24+
<Button onPress={close}>Agree</Button>
25+
</Dialog.Actions>
26+
</Dialog>
27+
);
28+
29+
export default DialogWithUndismissableBackButton;

‎example/src/Examples/Dialogs/index.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { default as DialogWithRadioBtns } from './DialogWithRadioBtns';
55
export { default as UndismissableDialog } from './UndismissableDialog';
66
export { default as DialogWithIcon } from './DialogWithIcon';
77
export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton';
8+
export { default as DialogWithUndismissableBackButton } from './DialogWithUndismissableBackButton';

‎src/components/Dialog/Dialog.tsx‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useInternalTheme } from '../../core/theming';
1313
import type { Elevation, ThemeProp } from '../../theme/types';
1414
import Modal from '../Modal';
1515
import type { SurfaceStyle } from '../Surface';
16+
import { DialogTitleIdContext } from './utils';
1617
import type { DialogChildProps } from './utils';
1718

1819
export type Props = {
@@ -28,6 +29,14 @@ export type Props = {
2829
* Callback that is called when the user dismisses the dialog.
2930
*/
3031
onDismiss?: () => void;
32+
/**
33+
* Accessibility label for dismissing the dialog if it's `dismissable`.
34+
*/
35+
dismissAccessibilityLabel?: string;
36+
/**
37+
* Accessible name for the dialog. On web, defaults to the text of `Dialog.Title`.
38+
*/
39+
'aria-label'?: string;
3140
/**
3241
* Determines Whether the dialog is visible.
3342
*/
@@ -95,6 +104,8 @@ const Dialog = ({
95104
dismissable = true,
96105
dismissableBackButton = dismissable,
97106
onDismiss,
107+
dismissAccessibilityLabel,
108+
'aria-label': ariaLabel,
98109
visible = false,
99110
style,
100111
theme: themeOverrides,
@@ -108,11 +119,16 @@ const Dialog = ({
108119

109120
const backgroundColor = theme.colors.surfaceContainerHigh;
110121

122+
const titleId = React.useId();
123+
111124
return (
112125
<Modal
126+
aria-label={ariaLabel}
127+
aria-labelledby={ariaLabel == null ? titleId : undefined}
113128
dismissable={dismissable}
114129
dismissableBackButton={dismissableBackButton}
115130
onDismiss={onDismiss}
131+
dismissAccessibilityLabel={dismissAccessibilityLabel}
116132
visible={visible}
117133
contentBackgroundColor={backgroundColor}
118134
contentBorderRadius={borderRadius}
@@ -128,17 +144,19 @@ const Dialog = ({
128144
testID={testID}
129145
overlayTestID={overlayTestID}
130146
>
131-
{React.Children.toArray(children)
132-
.filter((child) => child != null && typeof child !== 'boolean')
133-
.map((child, i) => {
134-
if (i === 0 && React.isValidElement<DialogChildProps>(child)) {
135-
return React.cloneElement(child, {
136-
style: [{ marginTop: 24 }, child.props.style],
137-
});
138-
}
147+
<DialogTitleIdContext.Provider value={titleId}>
148+
{React.Children.toArray(children)
149+
.filter((child) => child != null && typeof child !== 'boolean')
150+
.map((child, i) => {
151+
if (i === 0 && React.isValidElement<DialogChildProps>(child)) {
152+
return React.cloneElement(child, {
153+
style: [{ marginTop: 24 }, child.props.style],
154+
});
155+
}
139156

140-
return child;
141-
})}
157+
return child;
158+
})}
159+
</DialogTitleIdContext.Provider>
142160
</Modal>
143161
);
144162
};

‎src/components/Dialog/DialogTitle.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import { StyleSheet } from 'react-native';
33
import type { StyleProp, TextStyle } from 'react-native';
44

5+
import { DialogTitleIdContext } from './utils';
56
import { useInternalTheme } from '../../core/theming';
67
import type { ThemeProp } from '../../theme/types';
78
import Text from '../Typography/Text';
@@ -48,10 +49,12 @@ const DialogTitle = ({
4849
children,
4950
theme: themeOverrides,
5051
style,
52+
nativeID,
5153
...rest
5254
}: Props) => {
5355
const theme = useInternalTheme(themeOverrides);
5456
const { colors, fonts } = theme;
57+
const titleId = React.useContext(DialogTitleIdContext);
5558

5659
const headerTextStyle = {
5760
color: colors.onSurface,
@@ -62,6 +65,7 @@ const DialogTitle = ({
6265
<Text
6366
variant="headlineSmall"
6467
role="heading"
68+
nativeID={titleId ?? nativeID}
6569
style={[styles.text, styles.v3Text, headerTextStyle, style]}
6670
{...rest}
6771
>

‎src/components/Dialog/utils.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import * as React from 'react';
12
import type { StyleProp, ViewStyle } from 'react-native';
23

4+
export const DialogTitleIdContext = React.createContext<string | undefined>(
5+
undefined
6+
);
7+
38
export type DialogChildProps = {
49
style?: StyleProp<ViewStyle>;
510
};

‎src/components/Menu/Menu.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export type Props = {
6767
/**
6868
* Accessibility label for the overlay. This is read by the screen reader when the user taps outside the menu.
6969
*/
70-
overlayAccessibilityLabel?: string;
70+
dismissAccessibilityLabel?: string;
7171
/**
7272
* testID for the overlay that is displayed behind the menu.
7373
*/
@@ -189,7 +189,7 @@ const isCoordinate = (anchor: any): anchor is { x: number; y: number } =>
189189
const Menu = ({
190190
visible,
191191
statusBarHeight,
192-
overlayAccessibilityLabel = 'Close menu',
192+
dismissAccessibilityLabel = 'Close menu',
193193
overlayTestID,
194194
testID,
195195
anchor,
@@ -681,7 +681,7 @@ const Menu = ({
681681
{rendered ? (
682682
<Portal>
683683
<Pressable
684-
aria-label={overlayAccessibilityLabel}
684+
aria-label={dismissAccessibilityLabel}
685685
role="button"
686686
onPress={onDismiss}
687687
pointerEvents={visible ? 'auto' : 'none'}

0 commit comments

Comments
 (0)