diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index e51de006aa..054288fec5 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -158,6 +158,19 @@ e.g.:
### Modal
+`Modal` now uses a `Portal` internally and doesn't require an explicit `Portal` wrapper. So you need to remove any existing `Portal` wrappers around `Modal`:
+
+```diff
+-
+-
+- Content
+-
+-
++
++ Content
++
+```
+
- The `contentContainerStyle` prop no longer configures the background color or any border radius property. We have added new props for these:
- `contentBackgroundColor`
- `contentBorderRadius`
@@ -184,6 +197,19 @@ e.g.:
### Dialog
+`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`:
+
+```diff
+-
+-
+-
++
+```
+
- The default elevation changed from level `1` to level `3`.
- 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.
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..71eb2a7914 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,11 @@ 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.
*
* ## 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 +105,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 +222,50 @@ function Modal({
}
return (
-
-
-
+
-
+
- {children}
-
-
-
+
+ {children}
+
+
+
+
);
}
diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx
index 633b1c9a05..b11c1c6434 100644
--- a/src/components/Portal/Portal.tsx
+++ b/src/components/Portal/Portal.tsx
@@ -16,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
*/
@@ -42,7 +48,7 @@ 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);
@@ -50,7 +56,7 @@ const Portal = ({ children, theme: themeOverrides }: Props) => {
const reduceMotion = React.useContext(ReduceMotionContext);
return (
-
+
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 4238f48947..a095d8d58f 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,27 @@ 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 | undefined;
+ }
+ | {
+ type: 'update';
+ key: number;
+ children: React.ReactNode;
+ modal: boolean | undefined;
+ }
| { 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 | undefined) => number;
+ update: (
+ key: number,
+ children: React.ReactNode,
+ modal: boolean | undefined
+ ) => void;
unmount: (key: number) => void;
};
@@ -57,10 +70,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,27 +87,29 @@ export default class PortalHost extends React.Component {
this.manager = manager;
};
- private mount = (children: React.ReactNode) => {
+ private mount = (children: React.ReactNode, modal: boolean | undefined) => {
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 | undefined
+ ) => {
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.key === key) ||
- (o.type === 'update' && o.key === key)
+ (o) => (o.type === 'mount' || o.type === 'update') && o.key === key
);
if (index > -1) {
@@ -126,22 +141,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/PortalLayer.tsx b/src/components/Portal/PortalLayer.tsx
new file mode 100644
index 0000000000..543685eded
--- /dev/null
+++ b/src/components/Portal/PortalLayer.tsx
@@ -0,0 +1,19 @@
+import { Platform, View } from 'react-native';
+import type { ViewProps } from 'react-native';
+
+export type Props = ViewProps & {
+ inert: boolean;
+};
+
+export default function PortalLayer({ inert, children, ...rest }: Props) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/Portal/PortalManager.tsx b/src/components/Portal/PortalManager.tsx
index cc2bdbe1a7..307236f17f 100644
--- a/src/components/Portal/PortalManager.tsx
+++ b/src/components/Portal/PortalManager.tsx
@@ -1,33 +1,49 @@
import * as React from 'react';
-import { View, StyleSheet } from 'react-native';
+import { StyleSheet } from 'react-native';
+
+import PortalLayer from './PortalLayer';
+
+type Props = {
+ children: React.ReactNode;
+};
type State = {
portals: Array<{
key: number;
children: React.ReactNode;
+ modal: boolean | undefined;
}>;
};
/**
* Portal host is the component which actually renders all Portals.
*/
-export default class PortalManager extends React.PureComponent<{}, State> {
+export default class PortalManager extends React.Component {
state: State = {
portals: [],
};
- mount = (key: number, children: React.ReactNode) => {
+ mount = (
+ key: number,
+ children: React.ReactNode,
+ modal: boolean | undefined
+ ) => {
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 | undefined
+ ) =>
this.setState((state) => ({
portals: state.portals.map((item) => {
if (item.key === key) {
- return { ...item, children };
+ return { ...item, children, modal };
}
+
return item;
}),
}));
@@ -38,17 +54,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..129cf596f2 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({
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 7e8118a045..64ff1121bb 100644
--- a/src/components/__tests__/Portal.test.tsx
+++ b/src/components/__tests__/Portal.test.tsx
@@ -3,10 +3,12 @@ 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 {
+ ReduceMotionContext,
+ useReduceMotion,
+} from '../../theme/accessibility/ReduceMotionContext';
import Dialog from '../Dialog/Dialog';
import Modal from '../Modal';
import Portal from '../Portal/Portal';
@@ -28,57 +30,48 @@ it('renders portal with siblings', async () => {
expect(toJSON()).toMatchSnapshot();
});
-const PortalThemeContent = () => {
- const theme = useInternalTheme(undefined);
- const { direction } = useLocale();
+it('portal content reflects theme, direction, and reduced motion changes', async () => {
+ const PortalContent = () => {
+ const theme = useInternalTheme(undefined);
+ const { direction } = useLocale();
+ const reduceMotion = useReduceMotion();
- return {`${theme.animation.scale} ${direction}`};
-};
+ return (
+ {`${theme.animation.scale} ${direction} ${reduceMotion}`}
+ );
+ };
-it('passes local theme overrides and locale to portal content and updates them', async () => {
const { rerender } = await render(
-
-
-
-
-
+
+
+
+
+
+
+
);
- expect(screen.getByText('2 rtl')).toBeOnTheScreen();
+ expect(screen.getByText('2 rtl true')).toBeOnTheScreen();
await rerender(
-
-
-
-
-
+
+
+
+
+
+
+
);
- expect(screen.getByText('3 ltr')).toBeOnTheScreen();
- expect(screen.queryByText('2 rtl')).not.toBeOnTheScreen();
+ expect(screen.getByText('3 ltr false')).toBeOnTheScreen();
+ expect(screen.queryByText('2 rtl true')).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 () => {
+it('renders portals in source order when mounted together', async () => {
await render(
@@ -101,25 +94,141 @@ it('renders portals in source order when mounted in the same commit', async () =
expect(portals[2]).toHaveTextContent('third');
});
-it('stacks components mounted in the same commit in source order', async () => {
+it('keeps dialog content accessible above a modal when both are visible', async () => {
+ await render(
+
+ {}}>
+ modal content
+
+ {}}>
+ dialog content
+
+
+ );
+
+ expect(screen.getByText('dialog content')).toBeVisible();
+ expect(
+ screen.getByText('modal content', { includeHiddenElements: true })
+ ).not.toBeVisible();
+});
+
+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 portal content accessible above a modal', async () => {
+ await render(
+
+
+ dialog content
- {}}>
- dialog
-
+ menu content
+
+
+ );
+
+ expect(screen.getByText('menu content')).toBeVisible();
+ expect(screen.getByText('dialog content')).toBeVisible();
+});
+
+it('hides lower modal content from assistive technology', async () => {
+ await render(
+
+
+ lower dialog
+
+
+ upper dialog
+
+
+ );
+
+ expect(screen.getByText('upper dialog')).toBeVisible();
+ expect(
+ screen.getByText('lower dialog', { includeHiddenElements: true })
+ ).not.toBeVisible();
+});
+
+it('restores access to app content when a portal stops being modal', async () => {
+ const { rerender } = await render(
+
+ page content
+
+ modal content
);
- const layers = await screen.findAllByTestId('layer');
+ 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(layers).toHaveLength(2);
- expect(layers[0]).toHaveTextContent('modal');
- expect(layers[1]).toHaveTextContent('dialog');
+ 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..a3abc0943b 100644
--- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap
@@ -1,4576 +1,5226 @@
// 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`] = `
-
+<>
+
+ >
+
+
+
+
-
+>
`;