Skip to content
Open
Show file tree
Hide file tree
Changes from all 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';
20 changes: 15 additions & 5 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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,6 +93,7 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

/**
* The Modal component is a simple way to present content above an enclosing view.
* Give the modal an accessible name with `aria-label`.
*
* ## Usage
* ```js
Expand All @@ -108,6 +113,7 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
* <Modal
* visible={visible}
* onDismiss={hideModal}
* aria-label="Example modal"
* contentBackgroundColor="white"
* contentContainerStyle={containerStyle}
* >
Expand All @@ -128,6 +134,7 @@ function Modal({
dismissableBackButton = dismissable,
visible = false,
overlayAccessibilityLabel = 'Close modal',
'aria-label': ariaLabel,
overlayTestID,
onDismiss = () => {},
children,
Expand Down Expand Up @@ -179,7 +186,7 @@ function Modal({
}

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

Expand All @@ -193,7 +200,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 @@ -225,20 +232,20 @@ function Modal({
<Portal modal={visibleInternal} theme={themeOverrides}>
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
aria-modal
aria-live="polite"
style={StyleSheet.absoluteFill}
onAccessibilityEscape={onDismissCallback}
onAccessibilityEscape={dismissable ? onDismissCallback : undefined}
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 @@ -249,6 +256,9 @@ function Modal({
pointerEvents="box-none"
>
<Surface
role="dialog"
aria-modal
aria-label={ariaLabel}
theme={theme}
backgroundColor={contentBackgroundColor}
borderRadius={contentBorderRadius}
Expand Down
89 changes: 88 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 @@ -171,6 +171,24 @@ describe('Modal', () => {
expect(screen.queryByTestId('modal')).not.toBeOnTheScreen();
});

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

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

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 @@ -238,6 +256,39 @@ 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(
<Portal.Host>
<Modal
testID="modal"
visible
onDismiss={onDismiss}
dismissableBackButton={false}
>
{null}
</Modal>
</Portal.Host>
);

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 @@ -267,6 +318,30 @@ describe('Modal', () => {
});
});

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

await render(
<Portal.Host>
<Modal
testID="modal"
visible
onDismiss={onDismiss}
dismissable={false}
>
{null}
</Modal>
</Portal.Host>
);

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 @@ -576,3 +651,15 @@ describe('Modal', () => {
});
});
});

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

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