diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx
index 49bdd5f99d..7e39596f9b 100644
--- a/example/src/DrawerItems.tsx
+++ b/example/src/DrawerItems.tsx
@@ -10,7 +10,6 @@ import {
Dialog,
Drawer,
Palette,
- Portal,
Switch,
Text,
TouchableRipple,
@@ -240,24 +239,22 @@ function DrawerItems() {
>
)}
-
-
-
+
);
}
diff --git a/example/src/Examples/Dialogs/DialogWithCustomColors.tsx b/example/src/Examples/Dialogs/DialogWithCustomColors.tsx
index 75b5ee6b6e..df24a5f3f6 100644
--- a/example/src/Examples/Dialogs/DialogWithCustomColors.tsx
+++ b/example/src/Examples/Dialogs/DialogWithCustomColors.tsx
@@ -1,4 +1,4 @@
-import { Button, Portal, Dialog, Palette } from 'react-native-paper';
+import { Button, Dialog, Palette } from 'react-native-paper';
import { TextComponent } from './DialogTextComponent';
@@ -10,29 +10,27 @@ const DialogWithCustomColors = ({
close: () => void;
}) => {
return (
-
-
-
+
);
};
diff --git a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx
index e9a7189068..d7a37e5569 100644
--- a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx
+++ b/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx
@@ -1,4 +1,4 @@
-import { Button, Portal, Dialog, Palette } from 'react-native-paper';
+import { Button, Dialog, Palette } from 'react-native-paper';
import { TextComponent } from './DialogTextComponent';
@@ -9,28 +9,26 @@ const DialogWithDismissableBackButton = ({
visible: boolean;
close: () => void;
}) => (
-
-
-
+
);
export default DialogWithDismissableBackButton;
diff --git a/example/src/Examples/Dialogs/DialogWithIcon.tsx b/example/src/Examples/Dialogs/DialogWithIcon.tsx
index 6281e9bf6c..0d5f595f9c 100644
--- a/example/src/Examples/Dialogs/DialogWithIcon.tsx
+++ b/example/src/Examples/Dialogs/DialogWithIcon.tsx
@@ -1,6 +1,6 @@
import { StyleSheet } from 'react-native';
-import { Button, Portal, Dialog, Palette } from 'react-native-paper';
+import { Button, Dialog, Palette } from 'react-native-paper';
import { TextComponent } from './DialogTextComponent';
@@ -12,24 +12,22 @@ const DialogWithIcon = ({
close: () => void;
}) => {
return (
-
-
-
+
);
};
diff --git a/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx b/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx
index d854dcb587..f70c93d576 100644
--- a/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx
+++ b/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx
@@ -1,6 +1,6 @@
import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native';
-import { Dialog, Palette, Portal } from 'react-native-paper';
+import { Dialog, Palette } from 'react-native-paper';
import { TextComponent } from './DialogTextComponent';
@@ -12,21 +12,19 @@ const DialogWithLoadingIndicator = ({
close: () => void;
}) => {
return (
-
-
-
+
);
};
diff --git a/example/src/Examples/Dialogs/DialogWithLongText.tsx b/example/src/Examples/Dialogs/DialogWithLongText.tsx
index eeac1c7d3c..eed30ec358 100644
--- a/example/src/Examples/Dialogs/DialogWithLongText.tsx
+++ b/example/src/Examples/Dialogs/DialogWithLongText.tsx
@@ -1,6 +1,6 @@
import { Dimensions, ScrollView, StyleSheet } from 'react-native';
-import { Button, Portal, Dialog } from 'react-native-paper';
+import { Button, Dialog } from 'react-native-paper';
import { TextComponent } from './DialogTextComponent';
@@ -11,61 +11,59 @@ const DialogWithLongText = ({
visible: boolean;
close: () => void;
}) => (
-
-
-
+
);
const styles = StyleSheet.create({
diff --git a/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx b/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx
index 966422369e..5a729d6299 100644
--- a/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx
+++ b/example/src/Examples/Dialogs/DialogWithRadioBtns.tsx
@@ -3,7 +3,6 @@ import { ScrollView, View, StyleSheet } from 'react-native';
import {
Button,
- Portal,
Dialog,
RadioButton,
TouchableRipple,
@@ -22,73 +21,71 @@ const DialogWithRadioBtns = ({ visible, close }: Props) => {
const [checked, setChecked] = React.useState('normal');
return (
-
-
+
+ Option 4
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/example/src/Examples/Dialogs/UndismissableDialog.tsx b/example/src/Examples/Dialogs/UndismissableDialog.tsx
index 208c809967..79dc777484 100644
--- a/example/src/Examples/Dialogs/UndismissableDialog.tsx
+++ b/example/src/Examples/Dialogs/UndismissableDialog.tsx
@@ -1,4 +1,4 @@
-import { Button, Portal, Dialog, Palette } from 'react-native-paper';
+import { Button, Dialog, Palette } from 'react-native-paper';
import { TextComponent } from './DialogTextComponent';
@@ -9,20 +9,18 @@ const UndismissableDialog = ({
visible: boolean;
close: () => void;
}) => (
-
-
- Alert
-
- This is an undismissable dialog!!
-
-
-
-
-
-
-
+
+ Alert
+
+ This is an undismissable dialog!!
+
+
+
+
+
+
);
export default UndismissableDialog;
diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx
index 61af39bb31..9c75d4c08a 100644
--- a/src/components/Dialog/Dialog.tsx
+++ b/src/components/Dialog/Dialog.tsx
@@ -55,13 +55,12 @@ const DIALOG_ELEVATION: Elevation = 3;
/**
* Dialogs inform users about a specific task and may contain critical information, require decisions, or involve multiple tasks.
- * To render the `Dialog` above other components, you'll need to wrap it with the [`Portal`](../Portal) component.
*
* ## Usage
* ```js
* import * as React from 'react';
* import { View } from 'react-native';
- * import { Button, Dialog, Portal, PaperProvider, Text } from 'react-native-paper';
+ * import { Button, Dialog, PaperProvider, Text } from 'react-native-paper';
*
* const MyComponent = () => {
* const [visible, setVisible] = React.useState(false);
@@ -74,17 +73,15 @@ const DIALOG_ELEVATION: Elevation = 3;
*
*
*
- *
- *
- * Alert
- *
- * This is simple dialog
- *
- *
- *
- *
- *
- *
+ *
+ * Alert
+ *
+ * This is simple dialog
+ *
+ *
+ *
+ *
+ *
*
*
* );
diff --git a/src/components/Dialog/DialogActions.tsx b/src/components/Dialog/DialogActions.tsx
index 0a11970077..1314613820 100644
--- a/src/components/Dialog/DialogActions.tsx
+++ b/src/components/Dialog/DialogActions.tsx
@@ -24,7 +24,7 @@ export type Props = ViewProps & {
* ## Usage
* ```js
* import * as React from 'react';
- * import { Button, Dialog, Portal } from 'react-native-paper';
+ * import { Button, Dialog } from 'react-native-paper';
*
* const MyComponent = () => {
* const [visible, setVisible] = React.useState(false);
@@ -32,14 +32,12 @@ export type Props = ViewProps & {
* const hideDialog = () => setVisible(false);
*
* return (
- *
- *
- *
- *
- *
- *
- *
- *
+ *
+ *
+ *
+ *
+ *
+ *
* );
* };
*
diff --git a/src/components/Dialog/DialogContent.tsx b/src/components/Dialog/DialogContent.tsx
index a084188b32..9af1742608 100644
--- a/src/components/Dialog/DialogContent.tsx
+++ b/src/components/Dialog/DialogContent.tsx
@@ -16,7 +16,7 @@ export type Props = ViewProps & {
* ## Usage
* ```js
* import * as React from 'react';
- * import { Dialog, Portal, Text } from 'react-native-paper';
+ * import { Dialog, Text } from 'react-native-paper';
*
* const MyComponent = () => {
* const [visible, setVisible] = React.useState(false);
@@ -24,13 +24,11 @@ export type Props = ViewProps & {
* const hideDialog = () => setVisible(false);
*
* return (
- *
- *
- *
- * This is simple dialog
- *
- *
- *
+ *
+ *
+ * This is simple dialog
+ *
+ *
* );
* };
*
diff --git a/src/components/Dialog/DialogIcon.tsx b/src/components/Dialog/DialogIcon.tsx
index 791544aefe..efdec2e6a5 100644
--- a/src/components/Dialog/DialogIcon.tsx
+++ b/src/components/Dialog/DialogIcon.tsx
@@ -33,7 +33,7 @@ export type Props = {
* ```js
* import * as React from 'react';
* import { StyleSheet } from 'react-native';
- * import { Dialog, Portal, Text } from 'react-native-paper';
+ * import { Dialog, Text } from 'react-native-paper';
*
* const MyComponent = () => {
* const [visible, setVisible] = React.useState(false);
@@ -41,15 +41,13 @@ export type Props = {
* const hideDialog = () => setVisible(false);
*
* return (
- *
- *
- *
- * This is a title
- *
- * This is simple dialog
- *
- *
- *
+ *
+ *
+ * This is a title
+ *
+ * This is simple dialog
+ *
+ *
* );
* };
*
diff --git a/src/components/Dialog/DialogScrollArea.tsx b/src/components/Dialog/DialogScrollArea.tsx
index c2446f6149..bc0e03792d 100644
--- a/src/components/Dialog/DialogScrollArea.tsx
+++ b/src/components/Dialog/DialogScrollArea.tsx
@@ -25,7 +25,7 @@ export type Props = ViewProps & {
* ```js
* import * as React from 'react';
* import { ScrollView } from 'react-native';
- * import { Dialog, Portal, Text } from 'react-native-paper';
+ * import { Dialog, Text } from 'react-native-paper';
*
* const MyComponent = () => {
* const [visible, setVisible] = React.useState(false);
@@ -33,15 +33,13 @@ export type Props = ViewProps & {
* const hideDialog = () => setVisible(false);
*
* return (
- *
- *
- *
- *
- * This is a scrollable area
- *
- *
- *
- *
+ *
+ *
+ *
+ * This is a scrollable area
+ *
+ *
+ *
* );
* };
*
diff --git a/src/components/Dialog/DialogTitle.tsx b/src/components/Dialog/DialogTitle.tsx
index beff3be7d9..af36209cdc 100644
--- a/src/components/Dialog/DialogTitle.tsx
+++ b/src/components/Dialog/DialogTitle.tsx
@@ -24,7 +24,7 @@ export type Props = React.ComponentPropsWithRef & {
* ## Usage
* ```js
* import * as React from 'react';
- * import { Dialog, Portal, Text } from 'react-native-paper';
+ * import { Dialog, Text } from 'react-native-paper';
*
* const MyComponent = () => {
* const [visible, setVisible] = React.useState(false);
@@ -32,14 +32,12 @@ export type Props = React.ComponentPropsWithRef & {
* const hideDialog = () => setVisible(false);
*
* return (
- *
- *
- * This is a title
- *
- * This is simple dialog
- *
- *
- *
+ *
+ * This is a title
+ *
+ * This is simple dialog
+ *
+ *
* );
* };
*
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index fbc1197630..9a401c0c57 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -9,6 +9,7 @@ import Animated, {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import useLatestCallback from 'use-latest-callback';
+import Portal from './Portal/Portal';
import Surface from './Surface';
import type { Props as SurfaceProps, SurfaceStyle } from './Surface';
import { useInternalTheme } from '../core/theming';
@@ -88,13 +89,13 @@ 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.
+ * It renders itself in a [`Portal`](./Portal), so it appears above the rest of the app.
* Note that this modal is NOT accessible by default; if you need an accessible modal, please use the React Native Modal.
*
* ## Usage
* ```js
* import * as React from 'react';
- * import { Modal, Portal, Text, Button, PaperProvider } from 'react-native-paper';
+ * import { Modal, Text, Button, PaperProvider } from 'react-native-paper';
*
* const MyComponent = () => {
* const [visible, setVisible] = React.useState(false);
@@ -106,16 +107,14 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
*
* return (
*
- *
- *
- * Example Modal. Click outside this area to dismiss.
- *
- *
+ *
+ * Example Modal. Click outside this area to dismiss.
+ *
*
@@ -225,48 +224,50 @@ function Modal({
}
return (
-
-
-
+
-
+
- {children}
-
-
-
+
+ {children}
+
+
+
+
);
}
diff --git a/src/components/Portal/OverlayLayer.tsx b/src/components/Portal/OverlayLayer.tsx
new file mode 100644
index 0000000000..215b30bc14
--- /dev/null
+++ b/src/components/Portal/OverlayLayer.tsx
@@ -0,0 +1,33 @@
+import { Platform, View } from 'react-native';
+import type { ViewProps } from 'react-native';
+
+export type Props = ViewProps & {
+ /**
+ * Whether this layer sits below an overlay, and so should be unreachable by
+ * a screen reader and by the focus order.
+ */
+ inert: boolean | undefined;
+};
+
+/**
+ * `display: contents` so the extra node generates no box of its own. The
+ * layer's `flex` / `absoluteFill` styles and the child rule that
+ * `pointerEvents="box-none"` compiles to both keep working through it.
+ */
+const INERT_WRAPPER_STYLE = { display: 'contents' } as const;
+
+export default function OverlayLayer({ inert, children, ...rest }: Props) {
+ const layer = (
+
+ {children}
+
+ );
+
+ return Platform.OS === 'web' ? (
+
+ {layer}
+
+ ) : (
+ layer
+ );
+}
diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx
index 1326026913..b11c1c6434 100644
--- a/src/components/Portal/Portal.tsx
+++ b/src/components/Portal/Portal.tsx
@@ -8,6 +8,7 @@ import {
Provider as SettingsProvider,
} from '../../core/settings';
import { ThemeProvider, useInternalTheme } from '../../core/theming';
+import { ReduceMotionContext } from '../../theme/accessibility/ReduceMotionContext';
import type { ThemeProp } from '../../theme/types';
export type Props = {
@@ -15,6 +16,12 @@ export type Props = {
* Content of the `Portal`.
*/
children: React.ReactNode;
+ /**
+ * Whether the portal hides items below it from screen readers and focus order.
+ *
+ * Ensure it's set to true only when the modal is open.
+ */
+ modal?: boolean;
/**
* @optional
*/
@@ -41,18 +48,21 @@ export type Props = {
* export default MyComponent;
* ```
*/
-const Portal = ({ children, theme: themeOverrides }: Props) => {
+const Portal = ({ children, modal, theme: themeOverrides }: Props) => {
const theme = useInternalTheme(themeOverrides);
const { direction } = useLocale();
const settings = React.useContext(SettingsContext);
const manager = React.useContext(PortalContext);
+ const reduceMotion = React.useContext(ReduceMotionContext);
return (
-
+
-
- {children}
-
+
+
+ {children}
+
+
);
diff --git a/src/components/Portal/PortalConsumer.tsx b/src/components/Portal/PortalConsumer.tsx
index e933d2b24e..e148265104 100644
--- a/src/components/Portal/PortalConsumer.tsx
+++ b/src/components/Portal/PortalConsumer.tsx
@@ -5,19 +5,20 @@ import type { PortalMethods } from './PortalHost';
type Props = {
manager: PortalMethods;
children: React.ReactNode;
+ modal?: boolean;
};
export default class PortalConsumer extends React.Component {
componentDidMount() {
this.checkManager();
- this.key = this.props.manager.mount(this.props.children);
+ this.key = this.props.manager.mount(this.props.children, this.props.modal);
}
componentDidUpdate() {
this.checkManager();
- this.props.manager.update(this.key, this.props.children);
+ this.props.manager.update(this.key, this.props.children, this.props.modal);
}
componentWillUnmount() {
diff --git a/src/components/Portal/PortalHost.tsx b/src/components/Portal/PortalHost.tsx
index ecc20b8a72..1333c1ade3 100644
--- a/src/components/Portal/PortalHost.tsx
+++ b/src/components/Portal/PortalHost.tsx
@@ -1,5 +1,4 @@
import * as React from 'react';
-import { View, StyleSheet } from 'react-native';
import PortalManager from './PortalManager';
@@ -8,13 +7,18 @@ export type Props = {
};
type Operation =
- | { type: 'mount'; key: number; children: React.ReactNode }
- | { type: 'update'; key: number; children: React.ReactNode }
+ | { type: 'mount'; key: number; children: React.ReactNode; modal?: boolean }
+ | {
+ type: 'update';
+ key: number;
+ children: React.ReactNode;
+ modal?: boolean;
+ }
| { type: 'unmount'; key: number };
export type PortalMethods = {
- mount: (children: React.ReactNode) => number;
- update: (key: number, children: React.ReactNode) => void;
+ mount: (children: React.ReactNode, modal?: boolean) => number;
+ update: (key: number, children: React.ReactNode, modal?: boolean) => void;
unmount: (key: number) => void;
};
@@ -57,10 +61,10 @@ export default class PortalHost extends React.Component {
if (action) {
switch (action.type) {
case 'mount':
- manager.mount(action.key, action.children);
+ manager.mount(action.key, action.children, action.modal);
break;
case 'update':
- manager.update(action.key, action.children);
+ manager.update(action.key, action.children, action.modal);
break;
case 'unmount':
manager.unmount(action.key);
@@ -74,25 +78,31 @@ export default class PortalHost extends React.Component {
this.manager = manager;
};
- private mount = (children: React.ReactNode) => {
+ private mount = (children: React.ReactNode, modal?: boolean) => {
const key = this.nextKey++;
if (this.manager) {
- this.manager.mount(key, children);
+ this.manager.mount(key, children, modal);
} else {
- this.queue.push({ type: 'mount', key, children });
+ this.queue.push({ type: 'mount', key, children, modal });
}
return key;
};
- private update = (key: number, children: React.ReactNode) => {
+ private update = (
+ key: number,
+ children: React.ReactNode,
+ modal?: boolean
+ ) => {
if (this.manager) {
- this.manager.update(key, children);
+ this.manager.update(key, children, modal);
} else {
- const op: Operation = { type: 'mount', key, children };
+ const op: Operation = { type: 'mount', key, children, modal };
const index = this.queue.findIndex(
- (o) => o.type === 'mount' || (o.type === 'update' && o.key === key)
+ (o) =>
+ (o.type === 'mount' && o.key === key) ||
+ (o.type === 'update' && o.key === key)
);
if (index > -1) {
@@ -124,22 +134,10 @@ export default class PortalHost extends React.Component {
unmount: this.unmount,
}}
>
- {/* Need collapsable=false here to clip the elevations, otherwise they appear above Portal components */}
-
+
{this.props.children}
-
-
+
);
}
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
-});
diff --git a/src/components/Portal/PortalManager.tsx b/src/components/Portal/PortalManager.tsx
index cc2bdbe1a7..cf91acbe24 100644
--- a/src/components/Portal/PortalManager.tsx
+++ b/src/components/Portal/PortalManager.tsx
@@ -1,32 +1,39 @@
import * as React from 'react';
-import { View, StyleSheet } from 'react-native';
+import { StyleSheet } from 'react-native';
+
+import OverlayLayer from './OverlayLayer';
+
+type Props = {
+ children: React.ReactNode;
+};
type State = {
portals: Array<{
key: number;
children: React.ReactNode;
+ modal?: boolean;
}>;
};
/**
* Portal host is the component which actually renders all Portals.
*/
-export default class PortalManager extends React.PureComponent<{}, State> {
+export default class PortalManager extends React.PureComponent {
state: State = {
portals: [],
};
- mount = (key: number, children: React.ReactNode) => {
+ mount = (key: number, children: React.ReactNode, modal?: boolean) => {
this.setState((state) => ({
- portals: [...state.portals, { key, children }],
+ portals: [...state.portals, { key, children, modal }],
}));
};
- update = (key: number, children: React.ReactNode) =>
+ update = (key: number, children: React.ReactNode, modal?: boolean) =>
this.setState((state) => ({
portals: state.portals.map((item) => {
if (item.key === key) {
- return { ...item, children };
+ return { ...item, children, modal };
}
return item;
}),
@@ -38,17 +45,42 @@ export default class PortalManager extends React.PureComponent<{}, State> {
}));
render() {
- return this.state.portals.map(({ key, children }) => (
-
- {children}
-
- ));
+ const { portals } = this.state;
+
+ const topmostModalIndex = portals.findLastIndex((portal) => portal.modal);
+
+ return (
+ <>
+ = 0}
+ style={styles.container}
+ collapsable={
+ false /* Need collapsable=false here to clip the elevations, otherwise they appear above Portal components */
+ }
+ pointerEvents="box-none"
+ >
+ {this.props.children}
+
+ {portals.map(({ key, children }, index) => (
+
+ {children}
+
+ ))}
+ >
+ );
}
}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});
diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx
index 48d22a587b..60bb16cc02 100644
--- a/src/components/__tests__/Dialog.test.tsx
+++ b/src/components/__tests__/Dialog.test.tsx
@@ -12,6 +12,7 @@ import { act, userEvent } from '@testing-library/react-native';
import Dialog from '../../components/Dialog/Dialog';
import { render, screen } from '../../test-utils';
import Button from '../Button/Button';
+import Portal from '../Portal/Portal';
interface BackHandlerStatic extends RNBackHandlerStatic {
mockPressBack(): void;
@@ -23,9 +24,11 @@ const BackHandler = RNBackHandler as BackHandlerStatic;
describe('Dialog', () => {
it('should render passed children', async () => {
await render(
-
- This is simple dialog
-
+
+
+ This is simple dialog
+
+
);
expect(screen.getByTestId('dialog')).toHaveTextContent(
@@ -36,9 +39,11 @@ describe('Dialog', () => {
it('should call onDismiss when dismissable', async () => {
const onDismiss = jest.fn();
await render(
-
- This is simple dialog
-
+
+
+ This is simple dialog
+
+
);
await userEvent.press(screen.getByLabelText('Close modal'));
@@ -52,9 +57,16 @@ describe('Dialog', () => {
it('should not call onDismiss when dismissable is false', async () => {
const onDismiss = jest.fn();
await render(
-
- This is simple dialog
-
+
+
+ This is simple dialog
+
+
);
await userEvent.press(screen.getByLabelText('Close modal'));
@@ -69,15 +81,17 @@ describe('Dialog', () => {
Platform.OS = 'android';
const onDismiss = jest.fn();
await render(
-
- This is simple dialog
-
+
+
+ This is simple dialog
+
+
);
await userEvent.press(screen.getByLabelText('Close modal'));
@@ -96,11 +110,13 @@ describe('Dialog', () => {
it('should apply top margin to the first child if the dialog is V3', async () => {
await render(
-
-
- Test Dialog Content
-
-
+
+
+
+ Test Dialog Content
+
+
+
);
expect(screen.getByTestId('dialog-content')).toHaveStyle({
@@ -112,10 +128,12 @@ describe('Dialog', () => {
describe('DialogActions', () => {
it('should render passed children', async () => {
await render(
-
-
-
-
+
+
+
+
+
+
);
expect(screen.getByTestId('button-cancel')).toBeOnTheScreen();
@@ -124,10 +142,12 @@ describe('DialogActions', () => {
it('should apply default styles', async () => {
await render(
-
-
-
-
+
+
+
+
+
+
);
const dialogActionsContainer = screen.getByTestId('dialog-actions');
@@ -143,10 +163,12 @@ describe('DialogActions', () => {
it('should apply custom styles', async () => {
await render(
-
-
-
-
+
+
+
+
+
+
);
const dialogActionsContainer = screen.getByTestId('dialog-actions');
diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx
index 657eaf0992..84c9087bd5 100644
--- a/src/components/__tests__/Modal.test.tsx
+++ b/src/components/__tests__/Modal.test.tsx
@@ -8,6 +8,7 @@ import { render, screen } from '../../test-utils';
import { LightTheme } from '../../theme/schemes';
import { tokens } from '../../theme/tokens';
import Modal from '../Modal';
+import Portal from '../Portal/Portal';
const scrimAlpha = tokens.md.sys.scrim.alpha;
@@ -42,9 +43,11 @@ describe('Modal', () => {
describe('by default', () => {
it('should render passed children', async () => {
await render(
-
- Children
-
+
+
+ Children
+
+
);
expect(screen.getByTestId('modal')).toHaveTextContent('Children');
@@ -52,9 +55,11 @@ describe('Modal', () => {
it("should render a backdrop in default theme's color", async () => {
await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -64,17 +69,19 @@ describe('Modal', () => {
it('should render a custom backdrop color if specified', async () => {
await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -84,9 +91,11 @@ describe('Modal', () => {
it('should receive appropriate top and bottom insets', async () => {
const { toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(toJSON()).toMatchSnapshot();
@@ -97,9 +106,11 @@ describe('Modal', () => {
it('should invoke the onDismiss function immediately', async () => {
const onDismiss = jest.fn();
const { toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(onDismiss).not.toHaveBeenCalled();
@@ -126,9 +137,11 @@ describe('Modal', () => {
it('runs the closing animation if visible toggled', async () => {
const { rerender, toJSON } = await render(
- {}}>
- {null}
-
+
+ {}}>
+ {null}
+
+
);
expect(toJSON()).toMatchSnapshot();
@@ -136,9 +149,11 @@ describe('Modal', () => {
await userEvent.press(screen.getByLabelText('Close modal'));
await rerender(
- {}}>
- {null}
-
+
+ {}}>
+ {null}
+
+
);
expect(toJSON()).toMatchSnapshot();
@@ -153,16 +168,18 @@ describe('Modal', () => {
jest.runAllTimers();
});
- expect(toJSON()).toBeNull();
+ expect(screen.queryByTestId('modal')).not.toBeOnTheScreen();
});
describe('if closed via Android back button', () => {
it('invokes onDismiss', async () => {
const onDismiss = jest.fn();
const { toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(toJSON()).toMatchSnapshot();
@@ -192,14 +209,16 @@ describe('Modal', () => {
describe('if closed via touching backdrop', () => {
it('will run the animation but not fade out', async () => {
const { toJSON } = await render(
- {}}
- dismissable={false}
- >
- {null}
-
+
+ {}}
+ dismissable={false}
+ >
+ {null}
+
+
);
expect(toJSON()).toMatchSnapshot();
@@ -222,14 +241,16 @@ describe('Modal', () => {
it('should not invoke onDismiss', async () => {
const onDismiss = jest.fn();
await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(onDismiss).not.toHaveBeenCalled();
@@ -249,14 +270,16 @@ describe('Modal', () => {
describe('if closed via Android back button', () => {
it('will run the animation but not fade out', async () => {
const { toJSON } = await render(
- {}}
- dismissable={false}
- >
- {null}
-
+
+ {}}
+ dismissable={false}
+ >
+ {null}
+
+
);
expect(toJSON()).toMatchSnapshot();
@@ -282,14 +305,16 @@ describe('Modal', () => {
const onDismiss = jest.fn();
await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(onDismiss).not.toHaveBeenCalled();
@@ -313,17 +338,21 @@ describe('Modal', () => {
describe('from false to true (closed to open)', () => {
it('should run fade-in animation on opening', async () => {
const { rerender, toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.queryByTestId('modal')).not.toBeOnTheScreen();
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -345,9 +374,11 @@ describe('Modal', () => {
describe('from true to false (open to closed)', () => {
it('should run fade-out animation on closing', async () => {
const { rerender, toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -356,9 +387,11 @@ describe('Modal', () => {
expect(toJSON()).toMatchSnapshot();
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -377,17 +410,21 @@ describe('Modal', () => {
const onDismiss = jest.fn();
const { rerender } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(onDismiss).not.toHaveBeenCalled();
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(onDismiss).not.toHaveBeenCalled();
@@ -401,9 +438,11 @@ describe('Modal', () => {
it('should close even if the dialog is not dismissible', async () => {
const { rerender, toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -412,9 +451,11 @@ describe('Modal', () => {
expect(toJSON()).toMatchSnapshot();
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -435,9 +476,11 @@ describe('Modal', () => {
describe('while closing, back to true (visible)', () => {
it('should keep the modal open', async () => {
const { rerender, toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -446,9 +489,11 @@ describe('Modal', () => {
expect(toJSON()).toMatchSnapshot();
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -463,9 +508,11 @@ describe('Modal', () => {
});
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
await act(() => {
@@ -482,17 +529,21 @@ describe('Modal', () => {
describe('while opening, back to false (hidden)', () => {
it('should keep the modal closed', async () => {
const { rerender, toJSON } = await render(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.queryByLabelText('Close modal')).not.toBeOnTheScreen();
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
expect(screen.getByLabelText('Close modal')).toHaveStyle({
@@ -509,9 +560,11 @@ describe('Modal', () => {
expect(screen.getByLabelText('Close modal')).toBeOnTheScreen();
await rerender(
-
- {null}
-
+
+
+ {null}
+
+
);
await act(() => {
diff --git a/src/components/__tests__/Portal.test.tsx b/src/components/__tests__/Portal.test.tsx
index 6865d4d159..c1e2a22360 100644
--- a/src/components/__tests__/Portal.test.tsx
+++ b/src/components/__tests__/Portal.test.tsx
@@ -3,8 +3,10 @@ import { Text } from 'react-native';
import { expect, it, jest } from '@jest/globals';
import { LocaleProvider, useLocale } from '../../core/locale';
+import PaperProvider from '../../core/PaperProvider';
import { useInternalTheme } from '../../core/theming';
import { render, screen } from '../../test-utils';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import Dialog from '../Dialog/Dialog';
import Modal from '../Modal';
import Portal from '../Portal/Portal';
@@ -60,6 +62,22 @@ it('passes local theme overrides and locale to portal content and updates them',
expect(screen.queryByText('2 rtl')).not.toBeOnTheScreen();
});
+const PortalReduceMotionContent = () => (
+ {`reduce motion: ${useReduceMotion()}`}
+);
+
+it('passes the reduce motion preference to portal content', async () => {
+ await render(
+
+
+
+
+
+ );
+
+ expect(await screen.findByText('reduce motion: true')).toBeOnTheScreen();
+});
+
it('renders portals in source order when mounted in the same commit', async () => {
await render(
@@ -86,22 +104,141 @@ it('renders portals in source order when mounted in the same commit', async () =
it('stacks components mounted in the same commit in source order', async () => {
await render(
+ {}}>
+ modal
+
+ {}}>
+ dialog
+
+
+ );
+
+ const layers = await screen.findAllByTestId('layer', {
+ includeHiddenElements: true,
+ });
+
+ expect(layers).toHaveLength(2);
+ expect(layers[0]).toHaveTextContent('modal');
+ expect(layers[1]).toHaveTextContent('dialog');
+});
+
+it('hides the app content from assistive technology while a modal is open', async () => {
+ await render(
+
+ page content
+
+ modal content
+
+
+ );
+
+ expect(screen.getByText('modal content')).toBeVisible();
+
+ const pageContent = screen.getByText('page content', {
+ includeHiddenElements: true,
+ });
+
+ // Still mounted and painted - only hidden from assistive technology.
+ expect(pageContent).toBeOnTheScreen();
+ expect(pageContent).not.toBeVisible();
+});
+
+it('leaves the app content reachable for a portal that is not a modal', async () => {
+ await render(
+
+ page content
- {}}>
- modal
-
+ portal content
+
+
+ );
+
+ expect(screen.getByText('portal content')).toBeVisible();
+ expect(screen.getByText('page content')).toBeVisible();
+});
+
+it('keeps a portal opened on top of a modal reachable', async () => {
+ await render(
+
+
+ dialog content
- {}}>
- dialog
-
+ menu content
);
- const layers = await screen.findAllByTestId('layer');
+ expect(screen.getByText('menu content')).toBeVisible();
+ expect(screen.getByText('dialog content')).toBeVisible();
+});
- expect(layers).toHaveLength(2);
- expect(layers[0]).toHaveTextContent('modal');
- expect(layers[1]).toHaveTextContent('dialog');
+it('hides a modal that another modal was opened on top of', async () => {
+ await render(
+
+
+ lower dialog
+
+
+ upper dialog
+
+
+ );
+
+ expect(screen.getByText('upper dialog')).toBeVisible();
+ expect(
+ screen.getByText('lower dialog', { includeHiddenElements: true })
+ ).not.toBeVisible();
+});
+
+it('makes the app content reachable again once the modal closes', async () => {
+ const { rerender } = await render(
+
+ page content
+
+ modal content
+
+
+ );
+
+ expect(screen.getByText('modal content')).toBeVisible();
+ expect(
+ screen.getByText('page content', { includeHiddenElements: true })
+ ).not.toBeVisible();
+
+ await rerender(
+
+ page content
+
+ modal content
+
+
+ );
+
+ expect(screen.getByText('page content')).toBeVisible();
+});
+
+it('makes the app content reachable again once the modal unmounts', async () => {
+ const { rerender } = await render(
+
+ page content
+
+ modal content
+
+
+ );
+
+ expect(screen.getByText('modal content')).toBeVisible();
+ expect(
+ screen.getByText('page content', { includeHiddenElements: true })
+ ).not.toBeVisible();
+
+ await rerender(
+
+ page content
+
+ );
+
+ expect(screen.queryByText('modal content')).not.toBeOnTheScreen();
+ expect(screen.getByText('page content')).toBeVisible();
});
diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap
index 5b36c6a27a..70027ddba2 100644
--- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap
@@ -1,4576 +1,5221 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Modal by default should receive appropriate top and bottom insets 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open as non-dismissible modal if closed via Android back button will run the animation but not fade out 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open as non-dismissible modal if closed via Android back button will run the animation but not fade out 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open as non-dismissible modal if closed via Android back button will run the animation but not fade out 3`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open as non-dismissible modal if closed via touching backdrop will run the animation but not fade out 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open as non-dismissible modal if closed via touching backdrop will run the animation but not fade out 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open as non-dismissible modal if closed via touching backdrop will run the animation but not fade out 3`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open if backdrop touched should invoke the onDismiss function immediately 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open if backdrop touched should invoke the onDismiss function immediately 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open if closed via Android back button invokes onDismiss 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open if closed via Android back button invokes onDismiss 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open if closed via Android back button invokes onDismiss 3`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open runs the closing animation if visible toggled 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open runs the closing animation if visible toggled 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when open runs the closing animation if visible toggled 3`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes again during the open/close animation while closing, back to true (visible) should keep the modal open 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes again during the open/close animation while closing, back to true (visible) should keep the modal open 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes again during the open/close animation while closing, back to true (visible) should keep the modal open 3`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes again during the open/close animation while opening, back to false (hidden) should keep the modal closed 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes from false to true (closed to open) should run fade-in animation on opening 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes from false to true (closed to open) should run fade-in animation on opening 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes from true to false (open to closed) should close even if the dialog is not dismissible 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes from true to false (open to closed) should close even if the dialog is not dismissible 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes from true to false (open to closed) should run fade-out animation on closing 1`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;
exports[`Modal when visible prop changes from true to false (open to closed) should run fade-out animation on closing 2`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;