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() { )} - - - Changing to RTL - - - Due to Expo Go limitations it is impossible to change RTL - dynamically. To do so, you need to create a development build of - Example app or change it statically by setting{' '} - forcesRTL property to true in{' '} - app.json within{' '} - example directory. - - - - - - - + + Changing to RTL + + + Due to Expo Go limitations it is impossible to change RTL + dynamically. To do so, you need to create a development build of + Example app or change it statically by setting{' '} + forcesRTL property to true in{' '} + app.json within{' '} + example directory. + + + + + + ); } 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 ( - - - Alert - - - This is a dialog with custom colors - - - - - - - + + Alert + + + This is a dialog with custom colors + + + + + + ); }; 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; }) => ( - - - Alert - - - This is an undismissable dialog, however you can use hardware back - button to close it! - - - - - - - - + + Alert + + + This is an undismissable dialog, however you can use hardware back + button to close it! + + + + + + + ); 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 ( - - - - Dialog with Icon - - - This is a dialog with new component called DialogIcon. When icon is - displayed you should center the header. - - - - - - - - + + + Dialog with Icon + + + This is a dialog with new component called DialogIcon. When icon is + displayed you should center the header. + + + + + + + ); }; 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 ( - - - Progress Dialog - - - - Loading..... - - - - + + Progress Dialog + + + + Loading..... + + + ); }; 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; }) => ( - - - Alert - - - - Material is the metaphor - {'\n'} - {'\n'}A material metaphor is the unifying theory of a rationalized - space and a system of motion. The material is grounded in tactile - reality, inspired by the study of paper and ink, yet technologically - advanced and open to imagination and magic. - {'\n'} - {'\n'} - Surfaces and edges of the material provide visual cues that are - grounded in reality. The use of familiar tactile attributes helps - users quickly understand affordances. Yet the flexibility of the - material creates new affordances that supersede those in the - physical world, without breaking the rules of physics. - {'\n'} - {'\n'} - The fundamentals of light, surface, and movement are key to - conveying how objects move, interact, and exist in space and in - relation to each other. Realistic lighting shows seams, divides - space, and indicates moving parts. - {'\n'} - {'\n'}A material metaphor is the unifying theory of a rationalized - space and a system of motion. The material is grounded in tactile - reality, inspired by the study of paper and ink, yet technologically - advanced and open to imagination and magic. - {'\n'} - {'\n'} - Surfaces and edges of the material provide visual cues that are - grounded in reality. The use of familiar tactile attributes helps - users quickly understand affordances. Yet the flexibility of the - material creates new affordances that supersede those in the - physical world, without breaking the rules of physics. - {'\n'} - {'\n'} - The fundamentals of light, surface, and movement are key to - conveying how objects move, interact, and exist in space and in - relation to each other. Realistic lighting shows seams, divides - space, and indicates moving parts. - - - - - - - - + + Alert + + + + Material is the metaphor + {'\n'} + {'\n'}A material metaphor is the unifying theory of a rationalized + space and a system of motion. The material is grounded in tactile + reality, inspired by the study of paper and ink, yet technologically + advanced and open to imagination and magic. + {'\n'} + {'\n'} + Surfaces and edges of the material provide visual cues that are + grounded in reality. The use of familiar tactile attributes helps + users quickly understand affordances. Yet the flexibility of the + material creates new affordances that supersede those in the physical + world, without breaking the rules of physics. + {'\n'} + {'\n'} + The fundamentals of light, surface, and movement are key to conveying + how objects move, interact, and exist in space and in relation to each + other. Realistic lighting shows seams, divides space, and indicates + moving parts. + {'\n'} + {'\n'}A material metaphor is the unifying theory of a rationalized + space and a system of motion. The material is grounded in tactile + reality, inspired by the study of paper and ink, yet technologically + advanced and open to imagination and magic. + {'\n'} + {'\n'} + Surfaces and edges of the material provide visual cues that are + grounded in reality. The use of familiar tactile attributes helps + users quickly understand affordances. Yet the flexibility of the + material creates new affordances that supersede those in the physical + world, without breaking the rules of physics. + {'\n'} + {'\n'} + The fundamentals of light, surface, and movement are key to conveying + how objects move, interact, and exist in space and in relation to each + other. Realistic lighting shows seams, divides space, and indicates + moving parts. + + + + + + + ); 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 ( - - - Choose an option - - - - setChecked('normal')}> - - - - - - Option 1 - + + Choose an option + + + + setChecked('normal')}> + + + - - setChecked('second')}> - - - - - - Option 2 - + + Option 1 + + + + setChecked('second')}> + + + - - setChecked('third')}> - - - - - - Option 3 - + + Option 2 + + + + setChecked('third')}> + + + - - setChecked('fourth')}> - - - - - - Option 4 - + + Option 3 + + + + setChecked('fourth')}> + + + - - - - - - - - - - + + 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..df1e8db8cb 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -9,13 +9,13 @@ 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'; import { tokens } from '../theme/tokens'; import type { Elevation, ThemeProp } from '../theme/types'; -import { addEventListener } from '../utils/addEventListener'; -import { BackHandler } from '../utils/BackHandler/BackHandler'; +import { useOverlayDismiss } from '../utils/useOverlayDismiss'; const scrimAlpha = tokens.md.sys.scrim.alpha; @@ -88,13 +88,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 +106,14 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable); * * return ( * - * - * - * Example Modal. Click outside this area to dismiss. - * - * + * + * Example Modal. Click outside this area to dismiss. + * * @@ -176,27 +174,11 @@ function Modal({ return () => clearTimeout(timeout); }, [scale, visible, visibleInternal]); - React.useEffect(() => { - if (!visible) { - return undefined; - } - - const onHardwareBackPress = () => { - if (dismissable || dismissableBackButton) { - onDismissCallback(); - } - - return true; - }; - - const subscription = addEventListener( - BackHandler, - 'hardwareBackPress', - onHardwareBackPress - ); - - return () => subscription.remove(); - }, [dismissable, dismissableBackButton, onDismissCallback, visible]); + useOverlayDismiss({ + enabled: visible, + dismissable: dismissableBackButton, + onDismiss: onDismissCallback, + }); const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); @@ -225,48 +207,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..56e02a1ba6 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -1,13 +1,22 @@ 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 { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; import { act, userEvent } from '@testing-library/react-native'; 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; @@ -17,6 +26,7 @@ jest.mock('react-native-safe-area-context', () => ({ interface BackHandlerStatic extends RNBackHandlerStatic { mockPressBack(): void; + exitApp: jest.Mock<() => void>; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -39,12 +49,20 @@ describe('Modal', () => { /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ }); + // `exitApp` is one module-level `jest.fn` shared by every test in the file, + // and nothing clears it globally. + beforeEach(() => { + BackHandler.exitApp.mockClear(); + }); + describe('by default', () => { it('should render passed children', async () => { await render( - - Children - + + + Children + + ); expect(screen.getByTestId('modal')).toHaveTextContent('Children'); @@ -52,9 +70,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 +84,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 +106,11 @@ describe('Modal', () => { it('should receive appropriate top and bottom insets', async () => { const { toJSON } = await render( - - {null} - + + + {null} + + ); expect(toJSON()).toMatchSnapshot(); @@ -97,9 +121,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 +152,11 @@ describe('Modal', () => { it('runs the closing animation if visible toggled', async () => { const { rerender, toJSON } = await render( - {}}> - {null} - + + {}}> + {null} + + ); expect(toJSON()).toMatchSnapshot(); @@ -136,9 +164,11 @@ describe('Modal', () => { await userEvent.press(screen.getByLabelText('Close modal')); await rerender( - {}}> - {null} - + + {}}> + {null} + + ); expect(toJSON()).toMatchSnapshot(); @@ -153,16 +183,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 +224,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 +256,16 @@ describe('Modal', () => { it('should not invoke onDismiss', async () => { const onDismiss = jest.fn(); await render( - - {null} - + + + {null} + + ); expect(onDismiss).not.toHaveBeenCalled(); @@ -249,14 +285,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 +320,16 @@ describe('Modal', () => { const onDismiss = jest.fn(); await render( - - {null} - + + + {null} + + ); expect(onDismiss).not.toHaveBeenCalled(); @@ -306,6 +346,27 @@ describe('Modal', () => { expect(onDismiss).not.toHaveBeenCalled(); }); + + it('should not let the press leave the screen behind it', async () => { + await render( + + {}} + dismissable={false} + > + {null} + + + ); + + await act(() => { + BackHandler.mockPressBack(); + }); + + expect(BackHandler.exitApp).not.toHaveBeenCalled(); + }); }); }); @@ -313,17 +374,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 +410,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 +423,11 @@ describe('Modal', () => { expect(toJSON()).toMatchSnapshot(); await rerender( - - {null} - + + + {null} + + ); expect(screen.getByLabelText('Close modal')).toHaveStyle({ @@ -377,17 +446,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 +474,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 +487,11 @@ describe('Modal', () => { expect(toJSON()).toMatchSnapshot(); await rerender( - - {null} - + + + {null} + + ); expect(screen.getByLabelText('Close modal')).toHaveStyle({ @@ -435,9 +512,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 +525,11 @@ describe('Modal', () => { expect(toJSON()).toMatchSnapshot(); await rerender( - - {null} - + + + {null} + + ); expect(screen.getByLabelText('Close modal')).toHaveStyle({ @@ -463,9 +544,11 @@ describe('Modal', () => { }); await rerender( - - {null} - + + + {null} + + ); await act(() => { @@ -482,17 +565,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 +596,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`] = ` - +<> + + > + + + + - + `; diff --git a/src/utils/__tests__/useOverlayDismiss.test.tsx b/src/utils/__tests__/useOverlayDismiss.test.tsx new file mode 100644 index 0000000000..25bcc84ad9 --- /dev/null +++ b/src/utils/__tests__/useOverlayDismiss.test.tsx @@ -0,0 +1,309 @@ +import { BackHandler as RNBackHandler, Platform, Text } from 'react-native'; +import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; + +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act } from '@testing-library/react-native'; + +import Modal from '../../components/Modal'; +import Portal from '../../components/Portal/Portal'; +import { render, screen } from '../../test-utils'; +import { useOverlayDismiss } from '../useOverlayDismiss'; + +interface BackHandlerStatic extends RNBackHandlerStatic { + mockPressBack(): void; + exitApp: jest.Mock<() => void>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const BackHandler = RNBackHandler as BackHandlerStatic; + +const Probe = ({ + label, + visible, + dismissable = true, + onDismiss, +}: { + label: string; + visible: boolean; + dismissable?: boolean; + onDismiss?: () => void; +}) => { + useOverlayDismiss({ enabled: visible, dismissable, onDismiss }); + + return visible ? {label} : null; +}; + +const pressBack = async () => { + await act(() => { + BackHandler.mockPressBack(); + }); +}; + +describe('useOverlayDismiss', () => { + beforeEach(() => { + BackHandler.exitApp.mockClear(); + }); + + describe('when the back button is pressed', () => { + it('closes the overlay that opened last', async () => { + const onDismissFirst = jest.fn(); + const onDismissSecond = jest.fn(); + + const { rerender } = await render( + <> + + + + ); + + await rerender( + <> + + + + ); + + await pressBack(); + + expect(onDismissSecond).toHaveBeenCalledTimes(1); + expect(onDismissFirst).not.toHaveBeenCalled(); + }); + + it('closes one overlay per press instead of all of them at once', async () => { + const onDismissFirst = jest.fn(); + const onDismissSecond = jest.fn(); + + const { rerender } = await render( + <> + + + + ); + + await pressBack(); + + expect(onDismissSecond).toHaveBeenCalledTimes(1); + expect(onDismissFirst).not.toHaveBeenCalled(); + + await rerender( + <> + + + + ); + + await pressBack(); + + expect(onDismissFirst).toHaveBeenCalledTimes(1); + expect(onDismissSecond).toHaveBeenCalledTimes(1); + }); + + it('gives the press back to the overlay underneath when the one above unmounts', async () => { + const onDismissFirst = jest.fn(); + const onDismissSecond = jest.fn(); + + const { rerender } = await render( + <> + + + + ); + + await rerender( + + ); + + await pressBack(); + + expect(onDismissFirst).toHaveBeenCalledTimes(1); + expect(onDismissSecond).not.toHaveBeenCalled(); + }); + + it('leaves the press alone for an overlay that is mounted but closed', async () => { + const onDismiss = jest.fn(); + + await render( + + ); + + await pressBack(); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).toHaveBeenCalledTimes(1); + }); + + it('absorbs the press without closing anything when the overlay may not be dismissed', async () => { + const onDismissFirst = jest.fn(); + const onDismissBlocking = jest.fn(); + + await render( + <> + + + + ); + + await pressBack(); + + expect(onDismissBlocking).not.toHaveBeenCalled(); + // Neither the overlay underneath nor the screen behind it: an overlay + // the user may not dismiss still keeps the back button from leaving. + expect(onDismissFirst).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).not.toHaveBeenCalled(); + }); + + it('takes the press from a modal once something else opens over it', async () => { + const onDismissModal = jest.fn(); + const onDismissProbe = jest.fn(); + + const Overlays = ({ probeVisible }: { probeVisible: boolean }) => ( + + + modal + + + + ); + + const { rerender } = await render(); + + await act(() => { + jest.runAllTimers(); + }); + + await rerender(); + + // An open modal hides its siblings from assistive technology, so the + // probe is on screen but not reachable by an accessibility query. + expect( + screen.getByText('probe', { includeHiddenElements: true }) + ).toBeOnTheScreen(); + + await pressBack(); + + expect(onDismissProbe).toHaveBeenCalledTimes(1); + expect(onDismissModal).not.toHaveBeenCalled(); + }); + }); + + describe('when the Escape key is pressed on the web', () => { + let platform: { restore(): void }; + let keyDownListeners = new Set<(event: unknown) => void>(); + + beforeAll(() => { + platform = jest.replaceProperty(Platform, 'OS', 'web'); + + // There is no DOM under the React Native preset, and the hook only + // reaches for one on the web, so the test supplies what it touches. + Object.defineProperty(global, 'document', { + configurable: true, + value: { + addEventListener: ( + type: string, + listener: (event: unknown) => void, + capture?: boolean + ) => { + if (type === 'keydown' && capture === true) { + keyDownListeners.add(listener); + } + }, + removeEventListener: ( + _type: string, + listener: (event: unknown) => void + ) => { + keyDownListeners.delete(listener); + }, + }, + }); + }); + + afterAll(() => { + platform.restore(); + delete (global as { document?: unknown }).document; + }); + + beforeEach(() => { + keyDownListeners = new Set(); + }); + + const pressEscape = async ({ defaultPrevented = false } = {}) => { + const event = { + key: 'Escape', + defaultPrevented, + preventDefault: jest.fn(), + stopImmediatePropagation: jest.fn(), + }; + + await act(() => { + keyDownListeners.forEach((listener) => listener(event)); + }); + + return event; + }; + + it('closes the overlay that opened last', async () => { + const onDismissFirst = jest.fn(); + const onDismissSecond = jest.fn(); + + await render( + <> + + + + ); + + const event = await pressEscape(); + + expect(onDismissSecond).toHaveBeenCalledTimes(1); + expect(onDismissFirst).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(event.stopImmediatePropagation).toHaveBeenCalledTimes(1); + }); + + it('absorbs the key without closing anything when the overlay may not be dismissed', async () => { + const onDismiss = jest.fn(); + + await render( + + ); + + const event = await pressEscape(); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(event.stopImmediatePropagation).toHaveBeenCalledTimes(1); + }); + + it('stays out of the way once something nearer the key press handled it', async () => { + const onDismiss = jest.fn(); + + await render(); + + const event = await pressEscape({ defaultPrevented: true }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/useOverlayDismiss.tsx b/src/utils/useOverlayDismiss.tsx new file mode 100644 index 0000000000..cf84dddb8a --- /dev/null +++ b/src/utils/useOverlayDismiss.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +import useLatestCallback from 'use-latest-callback'; + +import { addEventListener } from './addEventListener'; +import { BackHandler } from './BackHandler/BackHandler'; + +const visibleOverlays: Array = []; + +let nextKey = 0; + +const registerOverlay = () => { + const key = nextKey++; + + visibleOverlays.push(key); + + return key; +}; + +const unregisterOverlay = (key: number) => { + const index = visibleOverlays.indexOf(key); + + if (index === -1) { + return; + } + + visibleOverlays.splice(index, 1); +}; + +const isTopmost = (key: number | undefined) => + key !== undefined && visibleOverlays[visibleOverlays.length - 1] === key; + +export type OverlayDismissOptions = { + enabled: boolean; + /** When false the press is absorbed, not passed on; same for Escape. */ + dismissable: boolean; + /** Called when the overlay should close. */ + onDismiss?: () => void; +}; + +/** + * Closes only the overlay on top when the user presses back or Escape. + */ +export function useOverlayDismiss({ + enabled, + dismissable, + onDismiss, +}: OverlayDismissOptions) { + const overlayKey = React.useRef(undefined); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + const registeredKey = registerOverlay(); + + overlayKey.current = registeredKey; + + return () => { + unregisterOverlay(registeredKey); + + overlayKey.current = undefined; + }; + }, [enabled]); + + const handleDismiss = useLatestCallback(() => { + if (!isTopmost(overlayKey.current)) { + return false; + } + + if (dismissable) { + onDismiss?.(); + } + + return true; + }); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + const subscription = addEventListener( + BackHandler, + 'hardwareBackPress', + handleDismiss + ); + + return () => subscription.remove(); + }, [enabled, handleDismiss]); + + React.useEffect(() => { + if (!enabled || Platform.OS !== 'web' || !('document' in global)) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + // Something nearer the key press already dealt with it. + if (event.key !== 'Escape' || event.defaultPrevented) { + return; + } + + if (!handleDismiss()) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + document.addEventListener('keydown', handleKeyDown, true); + + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [enabled, handleDismiss]); +}