From 9b8269dd7ee0902a730a6b99cd5791dbe7b26060 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 14 Sep 2026 15:53:01 +0200 Subject: [PATCH 1/9] fix(portal): re-provide the reduce motion preference to portal content Re-provide `ReduceMotionContext` in `Portal`, alongside the settings, locale and theme contexts already forwarded across the portal boundary, so portal content stops falling back to the context default of `false`. --- src/components/Portal/Portal.tsx | 10 +++++++--- src/components/__tests__/Portal.test.tsx | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index 1326026913..633b1c9a05 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 = { @@ -46,13 +47,16 @@ const Portal = ({ children, theme: themeOverrides }: Props) => { 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/__tests__/Portal.test.tsx b/src/components/__tests__/Portal.test.tsx index 6865d4d159..7e8118a045 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( From 37dc9a3545aaf363fa83824fd14b6ff05530714f Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 14 Sep 2026 15:55:05 +0200 Subject: [PATCH 2/9] fix(portal): match the key when replacing a queued portal update Compare the key when looking up the queued `mount` to replace, so an update that arrives before the `PortalManager` ref is attached no longer overwrites an unrelated queued portal. --- src/components/Portal/PortalHost.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Portal/PortalHost.tsx b/src/components/Portal/PortalHost.tsx index ecc20b8a72..4238f48947 100644 --- a/src/components/Portal/PortalHost.tsx +++ b/src/components/Portal/PortalHost.tsx @@ -92,7 +92,9 @@ export default class PortalHost extends React.Component { } else { const op: Operation = { type: 'mount', key, children }; 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) { From 12162e31ad8b959b5f4236d2fa45c1d7c4099cee Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 14 Sep 2026 19:52:51 +0200 Subject: [PATCH 3/9] feat(portal): hide content below an overlay from assistive technology Add an opt-in `overlay` prop to `Portal` that hides every layer below it -- the app content and any portal mounted earlier -- from assistive technology and from the web focus order, while portals mounted on top stay reachable. --- example/src/Examples/DialogExample.tsx | 12 ++ .../Examples/Dialogs/DialogWithOverlay.tsx | 31 +++++ example/src/Examples/Dialogs/index.tsx | 1 + src/components/Portal/OverlayLayer.tsx | 33 +++++ src/components/Portal/Portal.tsx | 14 +- src/components/Portal/PortalConsumer.tsx | 12 +- src/components/Portal/PortalHost.tsx | 53 ++++---- src/components/Portal/PortalManager.tsx | 69 +++++++--- src/components/__tests__/Portal.test.tsx | 121 ++++++++++++++++++ 9 files changed, 296 insertions(+), 50 deletions(-) create mode 100644 example/src/Examples/Dialogs/DialogWithOverlay.tsx create mode 100644 src/components/Portal/OverlayLayer.tsx diff --git a/example/src/Examples/DialogExample.tsx b/example/src/Examples/DialogExample.tsx index d926ae6616..799e855207 100644 --- a/example/src/Examples/DialogExample.tsx +++ b/example/src/Examples/DialogExample.tsx @@ -9,6 +9,7 @@ import { DialogWithIcon, DialogWithLoadingIndicator, DialogWithLongText, + DialogWithOverlay, DialogWithRadioBtns, UndismissableDialog, } from './Dialogs'; @@ -79,6 +80,13 @@ const DialogExample = () => { Dismissable back button )} + { visible={_getVisible('dialog7')} close={_toggleDialog('dialog7')} /> + ); }; diff --git a/example/src/Examples/Dialogs/DialogWithOverlay.tsx b/example/src/Examples/Dialogs/DialogWithOverlay.tsx new file mode 100644 index 0000000000..9aefa00408 --- /dev/null +++ b/example/src/Examples/Dialogs/DialogWithOverlay.tsx @@ -0,0 +1,31 @@ +import { Button, Portal, Dialog, Palette } from 'react-native-paper'; + +import { TextComponent } from './DialogTextComponent'; + +const DialogWithOverlay = ({ + visible, + close, +}: { + visible: boolean; + close: () => void; +}) => ( + + + Alert + + + While this dialog is open, everything behind it is hidden from screen + readers and skipped by the focus order! + + + + + + + + +); + +export default DialogWithOverlay; diff --git a/example/src/Examples/Dialogs/index.tsx b/example/src/Examples/Dialogs/index.tsx index 7af735d036..7b1beea59d 100644 --- a/example/src/Examples/Dialogs/index.tsx +++ b/example/src/Examples/Dialogs/index.tsx @@ -5,3 +5,4 @@ export { default as DialogWithRadioBtns } from './DialogWithRadioBtns'; export { default as UndismissableDialog } from './UndismissableDialog'; export { default as DialogWithIcon } from './DialogWithIcon'; export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton'; +export { default as DialogWithOverlay } from './DialogWithOverlay'; 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 633b1c9a05..73689d8340 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -16,6 +16,16 @@ export type Props = { * Content of the `Portal`. */ children: React.ReactNode; + /** + * Whether this portal hides everything below it -- the app content and any + * portal mounted before it -- from screen readers and the focus order. + * Portals mounted after it stay reachable. + * + * Tie it to whether the overlay is open rather than to how long it stays + * painted: a layer gives the screen back the moment it starts closing, so + * what is underneath is reachable again while the overlay fades out. + */ + overlay?: boolean; /** * @optional */ @@ -42,7 +52,7 @@ export type Props = { * export default MyComponent; * ``` */ -const Portal = ({ children, theme: themeOverrides }: Props) => { +const Portal = ({ children, overlay, theme: themeOverrides }: Props) => { const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); const settings = React.useContext(SettingsContext); @@ -50,7 +60,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..020b5c9060 100644 --- a/src/components/Portal/PortalConsumer.tsx +++ b/src/components/Portal/PortalConsumer.tsx @@ -5,19 +5,27 @@ import type { PortalMethods } from './PortalHost'; type Props = { manager: PortalMethods; children: React.ReactNode; + overlay?: 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.overlay + ); } componentDidUpdate() { this.checkManager(); - this.props.manager.update(this.key, this.props.children); + this.props.manager.update( + this.key, + this.props.children, + this.props.overlay + ); } componentWillUnmount() { diff --git a/src/components/Portal/PortalHost.tsx b/src/components/Portal/PortalHost.tsx index 4238f48947..360c652a45 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; overlay?: boolean } + | { + type: 'update'; + key: number; + children: React.ReactNode; + overlay?: boolean; + } | { type: 'unmount'; key: number }; export type PortalMethods = { - mount: (children: React.ReactNode) => number; - update: (key: number, children: React.ReactNode) => void; + mount: (children: React.ReactNode, overlay?: boolean) => number; + update: (key: number, children: React.ReactNode, overlay?: 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.overlay); break; case 'update': - manager.update(action.key, action.children); + manager.update(action.key, action.children, action.overlay); break; case 'unmount': manager.unmount(action.key); @@ -74,23 +78,27 @@ export default class PortalHost extends React.Component { this.manager = manager; }; - private mount = (children: React.ReactNode) => { + private mount = (children: React.ReactNode, overlay?: boolean) => { const key = this.nextKey++; if (this.manager) { - this.manager.mount(key, children); + this.manager.mount(key, children, overlay); } else { - this.queue.push({ type: 'mount', key, children }); + this.queue.push({ type: 'mount', key, children, overlay }); } return key; }; - private update = (key: number, children: React.ReactNode) => { + private update = ( + key: number, + children: React.ReactNode, + overlay?: boolean + ) => { if (this.manager) { - this.manager.update(key, children); + this.manager.update(key, children, overlay); } else { - const op: Operation = { type: 'mount', key, children }; + const op: Operation = { type: 'mount', key, children, overlay }; const index = this.queue.findIndex( (o) => (o.type === 'mount' && o.key === key) || @@ -126,22 +134,11 @@ 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..5a5b337c5f 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 = { + pageContent?: React.ReactNode; +}; type State = { portals: Array<{ key: number; children: React.ReactNode; + overlay?: 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, overlay?: boolean) => { this.setState((state) => ({ - portals: [...state.portals, { key, children }], + portals: [...state.portals, { key, children, overlay }], })); }; - update = (key: number, children: React.ReactNode) => + update = (key: number, children: React.ReactNode, overlay?: boolean) => this.setState((state) => ({ portals: state.portals.map((item) => { if (item.key === key) { - return { ...item, children }; + return { ...item, children, overlay }; } return item; }), @@ -38,17 +45,43 @@ export default class PortalManager extends React.PureComponent<{}, State> { })); render() { - return this.state.portals.map(({ key, children }) => ( - - {children} - - )); + const { portals } = this.state; + + const topmostOverlayIndex = portals.findLastIndex( + (portal) => portal.overlay + ); + + return ( + <> + {/* Need collapsable=false here to clip the elevations, otherwise they appear above Portal components */} + = 0} + style={styles.container} + collapsable={false} + pointerEvents="box-none" + > + {this.props.pageContent} + + {portals.map(({ key, children }, index) => ( + + {children} + + ))} + + ); } } + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/src/components/__tests__/Portal.test.tsx b/src/components/__tests__/Portal.test.tsx index 7e8118a045..0f3f2122a9 100644 --- a/src/components/__tests__/Portal.test.tsx +++ b/src/components/__tests__/Portal.test.tsx @@ -123,3 +123,124 @@ it('stacks components mounted in the same commit in source order', async () => { expect(layers[0]).toHaveTextContent('modal'); expect(layers[1]).toHaveTextContent('dialog'); }); + +it('hides the app content from assistive technology while an overlay is open', async () => { + await render( + + page content + + overlay content + + + ); + + expect(screen.getByText('overlay 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 an overlay', async () => { + await render( + + page content + + portal content + + + ); + + expect(screen.getByText('portal content')).toBeVisible(); + expect(screen.getByText('page content')).toBeVisible(); +}); + +it('keeps a portal opened on top of an overlay reachable', async () => { + await render( + + + dialog content + + + menu content + + + ); + + expect(screen.getByText('menu content')).toBeVisible(); + expect(screen.getByText('dialog content')).toBeVisible(); +}); + +it('hides an overlay that another overlay 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 overlay closes', async () => { + const { rerender } = await render( + + page content + + overlay content + + + ); + + expect(screen.getByText('overlay content')).toBeVisible(); + expect( + screen.getByText('page content', { includeHiddenElements: true }) + ).not.toBeVisible(); + + await rerender( + + page content + + overlay content + + + ); + + expect(screen.getByText('page content')).toBeVisible(); +}); + +it('makes the app content reachable again once the overlay unmounts', async () => { + const { rerender } = await render( + + page content + + overlay content + + + ); + + expect(screen.getByText('overlay content')).toBeVisible(); + expect( + screen.getByText('page content', { includeHiddenElements: true }) + ).not.toBeVisible(); + + await rerender( + + page content + + ); + + expect(screen.queryByText('overlay content')).not.toBeOnTheScreen(); + expect(screen.getByText('page content')).toBeVisible(); +}); From f511728ca725e001573e257d8fef35dd7dab98af Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 17 Sep 2026 15:17:11 +0200 Subject: [PATCH 4/9] refactor(portal): rename the overlay prop to modal Address review feedback on #5126: - rename the `overlay` prop to `modal` - rename `PortalManager`'s `pageContent` prop to `children` and make it required, since a portal host doesn't render a page - move the `collapsable` comment onto the prop it explains - rewrite the `modal` prop documentation --- .../Examples/Dialogs/DialogWithOverlay.tsx | 2 +- src/components/Portal/Portal.tsx | 14 +++---- src/components/Portal/PortalConsumer.tsx | 13 ++---- src/components/Portal/PortalHost.tsx | 31 +++++++------- src/components/Portal/PortalManager.tsx | 27 ++++++------ src/components/__tests__/Portal.test.tsx | 42 +++++++++---------- 6 files changed, 58 insertions(+), 71 deletions(-) diff --git a/example/src/Examples/Dialogs/DialogWithOverlay.tsx b/example/src/Examples/Dialogs/DialogWithOverlay.tsx index 9aefa00408..aaa1256613 100644 --- a/example/src/Examples/Dialogs/DialogWithOverlay.tsx +++ b/example/src/Examples/Dialogs/DialogWithOverlay.tsx @@ -9,7 +9,7 @@ const DialogWithOverlay = ({ visible: boolean; close: () => void; }) => ( - + Alert diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index 73689d8340..b11c1c6434 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -17,15 +17,11 @@ export type Props = { */ children: React.ReactNode; /** - * Whether this portal hides everything below it -- the app content and any - * portal mounted before it -- from screen readers and the focus order. - * Portals mounted after it stay reachable. + * Whether the portal hides items below it from screen readers and focus order. * - * Tie it to whether the overlay is open rather than to how long it stays - * painted: a layer gives the screen back the moment it starts closing, so - * what is underneath is reachable again while the overlay fades out. + * Ensure it's set to true only when the modal is open. */ - overlay?: boolean; + modal?: boolean; /** * @optional */ @@ -52,7 +48,7 @@ export type Props = { * export default MyComponent; * ``` */ -const Portal = ({ children, overlay, theme: themeOverrides }: Props) => { +const Portal = ({ children, modal, theme: themeOverrides }: Props) => { const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); const settings = React.useContext(SettingsContext); @@ -60,7 +56,7 @@ const Portal = ({ children, overlay, theme: themeOverrides }: Props) => { const reduceMotion = React.useContext(ReduceMotionContext); return ( - + diff --git a/src/components/Portal/PortalConsumer.tsx b/src/components/Portal/PortalConsumer.tsx index 020b5c9060..e148265104 100644 --- a/src/components/Portal/PortalConsumer.tsx +++ b/src/components/Portal/PortalConsumer.tsx @@ -5,27 +5,20 @@ import type { PortalMethods } from './PortalHost'; type Props = { manager: PortalMethods; children: React.ReactNode; - overlay?: boolean; + modal?: boolean; }; export default class PortalConsumer extends React.Component { componentDidMount() { this.checkManager(); - this.key = this.props.manager.mount( - this.props.children, - this.props.overlay - ); + 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.overlay - ); + 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 360c652a45..1333c1ade3 100644 --- a/src/components/Portal/PortalHost.tsx +++ b/src/components/Portal/PortalHost.tsx @@ -7,18 +7,18 @@ export type Props = { }; type Operation = - | { type: 'mount'; key: number; children: React.ReactNode; overlay?: boolean } + | { type: 'mount'; key: number; children: React.ReactNode; modal?: boolean } | { type: 'update'; key: number; children: React.ReactNode; - overlay?: boolean; + modal?: boolean; } | { type: 'unmount'; key: number }; export type PortalMethods = { - mount: (children: React.ReactNode, overlay?: boolean) => number; - update: (key: number, children: React.ReactNode, overlay?: boolean) => void; + mount: (children: React.ReactNode, modal?: boolean) => number; + update: (key: number, children: React.ReactNode, modal?: boolean) => void; unmount: (key: number) => void; }; @@ -61,10 +61,10 @@ export default class PortalHost extends React.Component { if (action) { switch (action.type) { case 'mount': - manager.mount(action.key, action.children, action.overlay); + manager.mount(action.key, action.children, action.modal); break; case 'update': - manager.update(action.key, action.children, action.overlay); + manager.update(action.key, action.children, action.modal); break; case 'unmount': manager.unmount(action.key); @@ -78,13 +78,13 @@ export default class PortalHost extends React.Component { this.manager = manager; }; - private mount = (children: React.ReactNode, overlay?: boolean) => { + private mount = (children: React.ReactNode, modal?: boolean) => { const key = this.nextKey++; if (this.manager) { - this.manager.mount(key, children, overlay); + this.manager.mount(key, children, modal); } else { - this.queue.push({ type: 'mount', key, children, overlay }); + this.queue.push({ type: 'mount', key, children, modal }); } return key; @@ -93,12 +93,12 @@ export default class PortalHost extends React.Component { private update = ( key: number, children: React.ReactNode, - overlay?: boolean + modal?: boolean ) => { if (this.manager) { - this.manager.update(key, children, overlay); + this.manager.update(key, children, modal); } else { - const op: Operation = { type: 'mount', key, children, overlay }; + const op: Operation = { type: 'mount', key, children, modal }; const index = this.queue.findIndex( (o) => (o.type === 'mount' && o.key === key) || @@ -134,10 +134,9 @@ export default class PortalHost extends React.Component { unmount: this.unmount, }} > - + + {this.props.children} + ); } diff --git a/src/components/Portal/PortalManager.tsx b/src/components/Portal/PortalManager.tsx index 5a5b337c5f..cf91acbe24 100644 --- a/src/components/Portal/PortalManager.tsx +++ b/src/components/Portal/PortalManager.tsx @@ -4,14 +4,14 @@ import { StyleSheet } from 'react-native'; import OverlayLayer from './OverlayLayer'; type Props = { - pageContent?: React.ReactNode; + children: React.ReactNode; }; type State = { portals: Array<{ key: number; children: React.ReactNode; - overlay?: boolean; + modal?: boolean; }>; }; @@ -23,17 +23,17 @@ export default class PortalManager extends React.PureComponent { portals: [], }; - mount = (key: number, children: React.ReactNode, overlay?: boolean) => { + mount = (key: number, children: React.ReactNode, modal?: boolean) => { this.setState((state) => ({ - portals: [...state.portals, { key, children, overlay }], + portals: [...state.portals, { key, children, modal }], })); }; - update = (key: number, children: React.ReactNode, overlay?: boolean) => + update = (key: number, children: React.ReactNode, modal?: boolean) => this.setState((state) => ({ portals: state.portals.map((item) => { if (item.key === key) { - return { ...item, children, overlay }; + return { ...item, children, modal }; } return item; }), @@ -47,25 +47,24 @@ export default class PortalManager extends React.PureComponent { render() { const { portals } = this.state; - const topmostOverlayIndex = portals.findLastIndex( - (portal) => portal.overlay - ); + const topmostModalIndex = portals.findLastIndex((portal) => portal.modal); return ( <> - {/* Need collapsable=false here to clip the elevations, otherwise they appear above Portal components */} = 0} + inert={topmostModalIndex >= 0} style={styles.container} - collapsable={false} + collapsable={ + false /* Need collapsable=false here to clip the elevations, otherwise they appear above Portal components */ + } pointerEvents="box-none" > - {this.props.pageContent} + {this.props.children} {portals.map(({ key, children }, index) => ( { expect(layers[1]).toHaveTextContent('dialog'); }); -it('hides the app content from assistive technology while an overlay is open', async () => { +it('hides the app content from assistive technology while a modal is open', async () => { await render( page content - - overlay content + + modal content ); - expect(screen.getByText('overlay content')).toBeVisible(); + expect(screen.getByText('modal content')).toBeVisible(); const pageContent = screen.getByText('page content', { includeHiddenElements: true, @@ -145,7 +145,7 @@ it('hides the app content from assistive technology while an overlay is open', a expect(pageContent).not.toBeVisible(); }); -it('leaves the app content reachable for a portal that is not an overlay', async () => { +it('leaves the app content reachable for a portal that is not a modal', async () => { await render( page content @@ -159,10 +159,10 @@ it('leaves the app content reachable for a portal that is not an overlay', async expect(screen.getByText('page content')).toBeVisible(); }); -it('keeps a portal opened on top of an overlay reachable', async () => { +it('keeps a portal opened on top of a modal reachable', async () => { await render( - + dialog content @@ -175,13 +175,13 @@ it('keeps a portal opened on top of an overlay reachable', async () => { expect(screen.getByText('dialog content')).toBeVisible(); }); -it('hides an overlay that another overlay was opened on top of', async () => { +it('hides a modal that another modal was opened on top of', async () => { await render( - + lower dialog - + upper dialog @@ -193,17 +193,17 @@ it('hides an overlay that another overlay was opened on top of', async () => { ).not.toBeVisible(); }); -it('makes the app content reachable again once the overlay closes', async () => { +it('makes the app content reachable again once the modal closes', async () => { const { rerender } = await render( page content - - overlay content + + modal content ); - expect(screen.getByText('overlay content')).toBeVisible(); + expect(screen.getByText('modal content')).toBeVisible(); expect( screen.getByText('page content', { includeHiddenElements: true }) ).not.toBeVisible(); @@ -211,8 +211,8 @@ it('makes the app content reachable again once the overlay closes', async () => await rerender( page content - - overlay content + + modal content ); @@ -220,17 +220,17 @@ it('makes the app content reachable again once the overlay closes', async () => expect(screen.getByText('page content')).toBeVisible(); }); -it('makes the app content reachable again once the overlay unmounts', async () => { +it('makes the app content reachable again once the modal unmounts', async () => { const { rerender } = await render( page content - - overlay content + + modal content ); - expect(screen.getByText('overlay content')).toBeVisible(); + expect(screen.getByText('modal content')).toBeVisible(); expect( screen.getByText('page content', { includeHiddenElements: true }) ).not.toBeVisible(); @@ -241,6 +241,6 @@ it('makes the app content reachable again once the overlay unmounts', async () = ); - expect(screen.queryByText('overlay content')).not.toBeOnTheScreen(); + expect(screen.queryByText('modal content')).not.toBeOnTheScreen(); expect(screen.getByText('page content')).toBeVisible(); }); From a7c3b518b32453a2eaeb53acf742bc0cdf6f759e Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 17 Sep 2026 15:17:49 +0200 Subject: [PATCH 5/9] feat(modal)!: render the modal in its own portal A `Modal` is an overlay, so it always needs a `Portal` with `modal` set to hide the content behind it. Render one itself instead of asking every call site to wrap the modal and pass the prop. BREAKING CHANGE: `Modal` and `Dialog` no longer need to be wrapped in a `Portal`. --- src/components/Modal.tsx | 101 +- src/components/__tests__/Dialog.test.tsx | 92 +- src/components/__tests__/Modal.test.tsx | 267 +- src/components/__tests__/Portal.test.tsx | 20 +- .../__snapshots__/Modal.test.tsx.snap | 7847 +++++++++-------- 5 files changed, 4523 insertions(+), 3804 deletions(-) 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/__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 eedd16e67e..c1e2a22360 100644 --- a/src/components/__tests__/Portal.test.tsx +++ b/src/components/__tests__/Portal.test.tsx @@ -104,20 +104,18 @@ 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 - - + {}}> + modal + + {}}> + dialog + ); - const layers = await screen.findAllByTestId('layer'); + const layers = await screen.findAllByTestId('layer', { + includeHiddenElements: true, + }); expect(layers).toHaveLength(2); expect(layers[0]).toHaveTextContent('modal'); 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`] = ` - +<> + + > + + + + - + `; From 0ee9b254e4a5148aa2f5e83b35e829e4a44105f2 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 17 Sep 2026 15:18:07 +0200 Subject: [PATCH 6/9] chore(example): drop the portal wrapper around dialogs Every dialog now hides the content behind it, so the dedicated "Inert background" example no longer has anything of its own to show. --- example/src/DrawerItems.tsx | 35 +++-- example/src/Examples/DialogExample.tsx | 12 -- .../Dialogs/DialogWithCustomColors.tsx | 46 +++---- .../DialogWithDismissableBackButton.tsx | 44 +++--- .../src/Examples/Dialogs/DialogWithIcon.tsx | 36 +++-- .../Dialogs/DialogWithLoadingIndicator.tsx | 30 ++--- .../Examples/Dialogs/DialogWithLongText.tsx | 110 ++++++++------- .../Examples/Dialogs/DialogWithOverlay.tsx | 31 ----- .../Examples/Dialogs/DialogWithRadioBtns.tsx | 125 +++++++++--------- .../Examples/Dialogs/UndismissableDialog.tsx | 28 ++-- example/src/Examples/Dialogs/index.tsx | 1 - 11 files changed, 218 insertions(+), 280 deletions(-) delete mode 100644 example/src/Examples/Dialogs/DialogWithOverlay.tsx 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/DialogExample.tsx b/example/src/Examples/DialogExample.tsx index 799e855207..d926ae6616 100644 --- a/example/src/Examples/DialogExample.tsx +++ b/example/src/Examples/DialogExample.tsx @@ -9,7 +9,6 @@ import { DialogWithIcon, DialogWithLoadingIndicator, DialogWithLongText, - DialogWithOverlay, DialogWithRadioBtns, UndismissableDialog, } from './Dialogs'; @@ -80,13 +79,6 @@ const DialogExample = () => { Dismissable back button )} - { visible={_getVisible('dialog7')} close={_toggleDialog('dialog7')} /> - ); }; 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/DialogWithOverlay.tsx b/example/src/Examples/Dialogs/DialogWithOverlay.tsx deleted file mode 100644 index aaa1256613..0000000000 --- a/example/src/Examples/Dialogs/DialogWithOverlay.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Button, Portal, Dialog, Palette } from 'react-native-paper'; - -import { TextComponent } from './DialogTextComponent'; - -const DialogWithOverlay = ({ - visible, - close, -}: { - visible: boolean; - close: () => void; -}) => ( - - - Alert - - - While this dialog is open, everything behind it is hidden from screen - readers and skipped by the focus order! - - - - - - - - -); - -export default DialogWithOverlay; 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/example/src/Examples/Dialogs/index.tsx b/example/src/Examples/Dialogs/index.tsx index 7b1beea59d..7af735d036 100644 --- a/example/src/Examples/Dialogs/index.tsx +++ b/example/src/Examples/Dialogs/index.tsx @@ -5,4 +5,3 @@ export { default as DialogWithRadioBtns } from './DialogWithRadioBtns'; export { default as UndismissableDialog } from './UndismissableDialog'; export { default as DialogWithIcon } from './DialogWithIcon'; export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton'; -export { default as DialogWithOverlay } from './DialogWithOverlay'; From 9b4baf9181d3df46442fbdb274a52b5b29ac44bf Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 17 Sep 2026 15:37:54 +0200 Subject: [PATCH 7/9] docs(dialog): drop the portal wrapper from the usage examples `Dialog` renders itself in a `Portal`, so the examples no longer need to wrap it in one. --- src/components/Dialog/Dialog.tsx | 23 ++++++++++------------ src/components/Dialog/DialogActions.tsx | 16 +++++++-------- src/components/Dialog/DialogContent.tsx | 14 ++++++------- src/components/Dialog/DialogIcon.tsx | 18 ++++++++--------- src/components/Dialog/DialogScrollArea.tsx | 18 ++++++++--------- src/components/Dialog/DialogTitle.tsx | 16 +++++++-------- 6 files changed, 46 insertions(+), 59 deletions(-) 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 + * + * * ); * }; * From 4ce304dd04909ed439c49662be6ae4dbedd5f9e5 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 17 Sep 2026 00:12:01 +0200 Subject: [PATCH 8/9] feat(modal): dismiss only the topmost overlay on back or escape --- src/components/Modal.tsx | 29 +- src/components/__tests__/Modal.test.tsx | 38 ++- .../__tests__/useOverlayDismiss.test.tsx | 309 ++++++++++++++++++ src/utils/useOverlayDismiss.tsx | 117 +++++++ 4 files changed, 469 insertions(+), 24 deletions(-) create mode 100644 src/utils/__tests__/useOverlayDismiss.test.tsx create mode 100644 src/utils/useOverlayDismiss.tsx diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 9a401c0c57..df1e8db8cb 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -15,8 +15,7 @@ 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; @@ -175,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); diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx index 84c9087bd5..56e02a1ba6 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -1,7 +1,15 @@ 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'; @@ -18,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 @@ -40,6 +49,12 @@ 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( @@ -331,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(); + }); }); }); 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]); +} From af85cdd4d2e3b1dfbc4f70f53c490cf8eabfe153 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Thu, 8 Oct 2026 12:31:12 +0200 Subject: [PATCH 9/9] feat(portal)!: dismiss the topmost modal portal from the portal host Each Portal.Host now closes only its topmost modal portal on the Android back button, the Escape key on web, and the screen reader's escape gesture, replacing the global useOverlayDismiss stack. BREAKING CHANGE: Modal and Dialog drop `dismissableBackButton`. `dismissable` now controls the back button, the Escape key, the screen reader's escape gesture and the dismiss button, and the new `dismissableOverlay` prop controls closing on an outside tap. --- docs/6.x/docs/guides/migration.md | 20 +- example/src/Examples/DialogExample.tsx | 36 +- .../DialogWithDismissableBackButton.tsx | 34 -- ...tsx => DialogWithUndismissableOverlay.tsx} | 10 +- example/src/Examples/Dialogs/index.tsx | 3 +- src/components/Dialog/Dialog.tsx | 12 +- src/components/Modal.tsx | 31 +- src/components/Portal/Portal.tsx | 31 +- src/components/Portal/PortalConsumer.tsx | 14 +- src/components/Portal/PortalDismiss.tsx | 71 +++ src/components/Portal/PortalHost.tsx | 25 +- src/components/Portal/PortalManager.tsx | 74 +-- src/components/__tests__/Dialog.test.tsx | 59 +-- src/components/__tests__/Modal.test.tsx | 25 +- src/components/__tests__/Portal.test.tsx | 486 +++++++++++++++++- .../__snapshots__/Modal.test.tsx.snap | 35 +- .../__tests__/useOverlayDismiss.test.tsx | 309 ----------- src/utils/useOverlayDismiss.tsx | 117 ----- 18 files changed, 745 insertions(+), 647 deletions(-) delete mode 100644 example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx rename example/src/Examples/Dialogs/{DialogWithUndismissableBackButton.tsx => DialogWithUndismissableOverlay.tsx} (63%) create mode 100644 src/components/Portal/PortalDismiss.tsx delete mode 100644 src/utils/__tests__/useOverlayDismiss.test.tsx delete mode 100644 src/utils/useOverlayDismiss.tsx diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 9b73d648ed..0fe463fa8f 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -219,7 +219,23 @@ The `overlayAccessibilityLabel` prop was renamed to `dismissAccessibilityLabel`, ``` -Previously, the Android back button dismissed the modal when `dismissable` was `true`, even if `dismissableBackButton` was `false`. The `dismissableBackButton` prop can now prevent the modal from being dismissed via the back button independently of the `dismissable` prop. +`dismissable` controls the Android back button, the Escape key, the screen reader's escape gesture, and the dismiss button described above. An outside tap is controlled by the new `dismissableOverlay` prop, which applies only when the modal is `dismissable`. The `dismissableBackButton` prop was removed. Setting `dismissable={false}` blocks every way of dismissing the modal. + +To ignore an outside tap and still allow the back button, the Escape key, and the screen reader's escape gesture to close the modal, use `dismissableOverlay={false}`: + +```diff + + Content + +``` + +`Dialog` uses the same `dismissable` and `dismissableOverlay` props. ### Dialog @@ -246,7 +262,7 @@ The dialog now has the `dialog` role. On web, the dialog's accessible name is se ``` -When the dialog is `dismissable`, screen reader users can dismiss it with a visually hidden button inside the dialog. You can change the button's accessibility label with the new `dismissAccessibilityLabel` prop. +When the dialog is `dismissable`, screen reader users can dismiss it with a visually hidden button inside the dialog. You can change the button's accessibility label with the new `dismissAccessibilityLabel` prop. `dismissable` and `dismissableOverlay` behave the same way as on `Modal`, and `dismissableBackButton` was removed. - 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/Examples/DialogExample.tsx b/example/src/Examples/DialogExample.tsx index b12cbb5c84..71e6fd8f04 100644 --- a/example/src/Examples/DialogExample.tsx +++ b/example/src/Examples/DialogExample.tsx @@ -1,16 +1,15 @@ import * as React from 'react'; -import { Platform, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import { Button } from 'react-native-paper'; import { DialogWithCustomColors, - DialogWithDismissableBackButton, DialogWithIcon, DialogWithLoadingIndicator, DialogWithLongText, DialogWithRadioBtns, - DialogWithUndismissableBackButton, + DialogWithUndismissableOverlay, UndismissableDialog, } from './Dialogs'; import ScreenWrapper from '../ScreenWrapper'; @@ -71,24 +70,13 @@ const DialogExample = () => { > With icon - {Platform.OS === 'android' && ( - - )} - {Platform.OS === 'android' && ( - - )} + { visible={_getVisible('dialog6')} close={_toggleDialog('dialog6')} /> - - ); }; diff --git a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx deleted file mode 100644 index d7a37e5569..0000000000 --- a/example/src/Examples/Dialogs/DialogWithDismissableBackButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, Dialog, Palette } from 'react-native-paper'; - -import { TextComponent } from './DialogTextComponent'; - -const DialogWithDismissableBackButton = ({ - visible, - close, -}: { - visible: boolean; - close: () => void; -}) => ( - - 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/DialogWithUndismissableBackButton.tsx b/example/src/Examples/Dialogs/DialogWithUndismissableOverlay.tsx similarity index 63% rename from example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx rename to example/src/Examples/Dialogs/DialogWithUndismissableOverlay.tsx index d775ba011c..7e9998368c 100644 --- a/example/src/Examples/Dialogs/DialogWithUndismissableBackButton.tsx +++ b/example/src/Examples/Dialogs/DialogWithUndismissableOverlay.tsx @@ -2,19 +2,19 @@ import { Button, Dialog, Palette } from 'react-native-paper'; import { TextComponent } from './DialogTextComponent'; -const DialogWithUndismissableBackButton = ({ +const DialogWithUndismissableOverlay = ({ visible, close, }: { visible: boolean; close: () => void; }) => ( - + Alert - This dialog can be dismissed by tapping outside, however the hardware - back button will not close it! + Tapping outside will not close this dialog, however you can still close + it with the back button or the Escape key! @@ -26,4 +26,4 @@ const DialogWithUndismissableBackButton = ({ ); -export default DialogWithUndismissableBackButton; +export default DialogWithUndismissableOverlay; diff --git a/example/src/Examples/Dialogs/index.tsx b/example/src/Examples/Dialogs/index.tsx index 5ae48db11f..cee460952c 100644 --- a/example/src/Examples/Dialogs/index.tsx +++ b/example/src/Examples/Dialogs/index.tsx @@ -4,5 +4,4 @@ export { default as DialogWithLongText } from './DialogWithLongText'; export { default as DialogWithRadioBtns } from './DialogWithRadioBtns'; export { default as UndismissableDialog } from './UndismissableDialog'; export { default as DialogWithIcon } from './DialogWithIcon'; -export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton'; -export { default as DialogWithUndismissableBackButton } from './DialogWithUndismissableBackButton'; +export { default as DialogWithUndismissableOverlay } from './DialogWithUndismissableOverlay'; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 5b273151ec..0a550bf02e 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -18,13 +18,15 @@ import type { DialogChildProps } from './utils'; export type Props = { /** - * Determines whether clicking outside the dialog dismiss it. + * Determines whether the user can dismiss the dialog with the Android back button, + * the Escape key, or the screen reader's escape gesture. */ dismissable?: boolean; /** - * Determines whether clicking Android hardware back button dismiss dialog. + * Determines whether clicking outside the dialog dismisses it. + * Applies only when the dialog is `dismissable`. */ - dismissableBackButton?: boolean; + dismissableOverlay?: boolean; /** * Callback that is called when the user dismisses the dialog. */ @@ -102,7 +104,7 @@ const DIALOG_ELEVATION: Elevation = 3; const Dialog = ({ children, dismissable = true, - dismissableBackButton = dismissable, + dismissableOverlay = true, onDismiss, dismissAccessibilityLabel, 'aria-label': ariaLabel, @@ -126,7 +128,7 @@ const Dialog = ({ aria-label={ariaLabel} aria-labelledby={ariaLabel == null ? titleId : undefined} dismissable={dismissable} - dismissableBackButton={dismissableBackButton} + dismissableOverlay={dismissableOverlay} onDismiss={onDismiss} dismissAccessibilityLabel={dismissAccessibilityLabel} visible={visible} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index d9c6599443..06b28d4105 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -15,19 +15,20 @@ 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 { useOverlayDismiss } from '../utils/useOverlayDismiss'; const scrimAlpha = tokens.md.sys.scrim.alpha; export type Props = { /** - * Determines whether clicking outside the modal dismisses it. + * Determines whether the user can dismiss the modal with the Android back button, + * the Escape key, or the screen reader's escape gesture. */ dismissable?: boolean; /** - * Determines whether clicking Android hardware back button dismisses the dialog. + * Determines whether clicking outside the modal dismisses it. + * Applies only when the modal is `dismissable`. */ - dismissableBackButton?: boolean; + dismissableOverlay?: boolean; /** * Callback that is called when the user dismisses the modal. */ @@ -135,7 +136,7 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable); */ function Modal({ dismissable = true, - dismissableBackButton = dismissable, + dismissableOverlay = true, visible = false, dismissAccessibilityLabel = 'Close modal', 'aria-label': ariaLabel, @@ -185,12 +186,6 @@ function Modal({ return () => clearTimeout(timeout); }, [scale, visible, visibleInternal]); - useOverlayDismiss({ - enabled: visible, - dismissable: dismissableBackButton, - onDismiss: onDismissCallback, - }); - const transitionTimingFunction = cubicBezier(1 / 3, 1, 2 / 3, 1); const backdropTransitionStyle: AnimatedStyle = { @@ -217,21 +212,27 @@ function Modal({ return null; } + const overlayDismisses = dismissable && dismissableOverlay; + return ( - + diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index b11c1c6434..c2ee421977 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -17,11 +17,22 @@ export type Props = { */ children: React.ReactNode; /** - * Whether the portal hides items below it from screen readers and focus order. + * Whether the portal hides items below it from screen readers and focus order, + * and listens for the Android back button, the Escape key on web + * and the screen reader's escape gesture. * * Ensure it's set to true only when the modal is open. */ modal?: boolean; + /** + * Callback that is called when the user asks to close a `modal` portal. + * Only the topmost `modal` portal in a `Portal.Host` can be dismissed. + */ + onDismiss?: () => void; + /** + * Whether the user can dismiss a `modal` portal. + */ + dismissable?: boolean; /** * @optional */ @@ -34,6 +45,9 @@ export type Props = { * It requires a [`Portal.Host`](PortalHost) component to be rendered somewhere in the parent tree. * Note that if you're using the `Provider` component, this already includes a `Portal.Host`. * + * A topmost `modal` portal can be closed with the Android back button, the Escape key on web + * and the screen reader's escape gesture through `onDismiss`. + * * ## Usage * ```js * import * as React from 'react'; @@ -48,7 +62,13 @@ export type Props = { * export default MyComponent; * ``` */ -const Portal = ({ children, modal, theme: themeOverrides }: Props) => { +const Portal = ({ + children, + modal, + onDismiss, + dismissable = true, + theme: themeOverrides, +}: Props) => { const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); const settings = React.useContext(SettingsContext); @@ -56,7 +76,12 @@ const Portal = ({ children, modal, theme: themeOverrides }: Props) => { const reduceMotion = React.useContext(ReduceMotionContext); return ( - + diff --git a/src/components/Portal/PortalConsumer.tsx b/src/components/Portal/PortalConsumer.tsx index e148265104..e04c83acbf 100644 --- a/src/components/Portal/PortalConsumer.tsx +++ b/src/components/Portal/PortalConsumer.tsx @@ -1,24 +1,24 @@ import * as React from 'react'; import type { PortalMethods } from './PortalHost'; +import type { PortalOptions } from './PortalManager'; -type Props = { +type Props = PortalOptions & { 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.props.modal); + this.key = this.props.manager.mount(this.props.children, this.getOptions()); } componentDidUpdate() { this.checkManager(); - this.props.manager.update(this.key, this.props.children, this.props.modal); + this.props.manager.update(this.key, this.props.children, this.getOptions()); } componentWillUnmount() { @@ -29,6 +29,12 @@ export default class PortalConsumer extends React.Component { private key: any; + private getOptions(): PortalOptions { + const { modal, onDismiss, dismissable } = this.props; + + return { modal, onDismiss, dismissable }; + } + private checkManager() { if (!this.props.manager) { throw new Error( diff --git a/src/components/Portal/PortalDismiss.tsx b/src/components/Portal/PortalDismiss.tsx new file mode 100644 index 0000000000..5ee1123e5f --- /dev/null +++ b/src/components/Portal/PortalDismiss.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +import useLatestCallback from 'use-latest-callback'; + +import type { PortalOptions } from './PortalManager'; +import { addEventListener } from '../../utils/addEventListener'; +import { BackHandler } from '../../utils/BackHandler/BackHandler'; + +export type Props = Pick & { + enabled: boolean; +}; + +export default function PortalDismiss({ + enabled, + dismissable, + onDismiss, +}: Props) { + const handleBackPress = useLatestCallback(() => { + if (!enabled) { + return false; + } + + if (dismissable) { + onDismiss?.(); + } + + return true; + }); + + const handleKeyDown = useLatestCallback((event: KeyboardEvent) => { + if ( + !enabled || + event.key !== 'Escape' || + event.defaultPrevented || + event.isComposing + ) { + return; + } + + event.preventDefault(); + + if (dismissable) { + onDismiss?.(); + } + }); + + React.useEffect(() => { + const backSubscription = addEventListener( + BackHandler, + 'hardwareBackPress', + handleBackPress + ); + + const listensForKeys = Platform.OS === 'web' && 'document' in global; + + if (listensForKeys) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => { + backSubscription.remove(); + + if (listensForKeys) { + document.removeEventListener('keydown', handleKeyDown); + } + }; + }, [handleBackPress, handleKeyDown]); + + return null; +} diff --git a/src/components/Portal/PortalHost.tsx b/src/components/Portal/PortalHost.tsx index a095d8d58f..a1762bc099 100644 --- a/src/components/Portal/PortalHost.tsx +++ b/src/components/Portal/PortalHost.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import PortalManager from './PortalManager'; +import type { PortalOptions } from './PortalManager'; export type Props = { children: React.ReactNode; @@ -11,22 +12,22 @@ type Operation = type: 'mount'; key: number; children: React.ReactNode; - modal: boolean | undefined; + options: PortalOptions; } | { type: 'update'; key: number; children: React.ReactNode; - modal: boolean | undefined; + options: PortalOptions; } | { type: 'unmount'; key: number }; export type PortalMethods = { - mount: (children: React.ReactNode, modal: boolean | undefined) => number; + mount: (children: React.ReactNode, options: PortalOptions) => number; update: ( key: number, children: React.ReactNode, - modal: boolean | undefined + options: PortalOptions ) => void; unmount: (key: number) => void; }; @@ -70,10 +71,10 @@ export default class PortalHost extends React.Component { if (action) { switch (action.type) { case 'mount': - manager.mount(action.key, action.children, action.modal); + manager.mount(action.key, action.children, action.options); break; case 'update': - manager.update(action.key, action.children, action.modal); + manager.update(action.key, action.children, action.options); break; case 'unmount': manager.unmount(action.key); @@ -87,13 +88,13 @@ export default class PortalHost extends React.Component { this.manager = manager; }; - private mount = (children: React.ReactNode, modal: boolean | undefined) => { + private mount = (children: React.ReactNode, options: PortalOptions) => { const key = this.nextKey++; if (this.manager) { - this.manager.mount(key, children, modal); + this.manager.mount(key, children, options); } else { - this.queue.push({ type: 'mount', key, children, modal }); + this.queue.push({ type: 'mount', key, children, options }); } return key; @@ -102,12 +103,12 @@ export default class PortalHost extends React.Component { private update = ( key: number, children: React.ReactNode, - modal: boolean | undefined + options: PortalOptions ) => { if (this.manager) { - this.manager.update(key, children, modal); + this.manager.update(key, children, options); } else { - const op: Operation = { type: 'mount', key, children, modal }; + const op: Operation = { type: 'mount', key, children, options }; const index = this.queue.findIndex( (o) => (o.type === 'mount' || o.type === 'update') && o.key === key ); diff --git a/src/components/Portal/PortalManager.tsx b/src/components/Portal/PortalManager.tsx index 307236f17f..3693a5d304 100644 --- a/src/components/Portal/PortalManager.tsx +++ b/src/components/Portal/PortalManager.tsx @@ -1,18 +1,26 @@ import * as React from 'react'; import { StyleSheet } from 'react-native'; +import PortalDismiss from './PortalDismiss'; import PortalLayer from './PortalLayer'; +export type PortalOptions = { + modal: boolean | undefined; + onDismiss: (() => void) | undefined; + dismissable: boolean; +}; + type Props = { children: React.ReactNode; }; type State = { - portals: Array<{ - key: number; - children: React.ReactNode; - modal: boolean | undefined; - }>; + portals: Array< + PortalOptions & { + key: number; + children: React.ReactNode; + } + >; }; /** @@ -23,25 +31,17 @@ export default class PortalManager extends React.Component { portals: [], }; - mount = ( - key: number, - children: React.ReactNode, - modal: boolean | undefined - ) => { + mount = (key: number, children: React.ReactNode, options: PortalOptions) => { this.setState((state) => ({ - portals: [...state.portals, { key, children, modal }], + portals: [...state.portals, { key, children, ...options }], })); }; - update = ( - key: number, - children: React.ReactNode, - modal: boolean | undefined - ) => + update = (key: number, children: React.ReactNode, options: PortalOptions) => this.setState((state) => ({ portals: state.portals.map((item) => { if (item.key === key) { - return { ...item, children, modal }; + return { ...item, children, ...options }; } return item; @@ -70,19 +70,33 @@ export default class PortalManager extends React.Component { > {this.props.children} - {portals.map(({ key, children }, index) => ( - - {children} - - ))} + {portals.map( + ({ key, children, modal, onDismiss, dismissable }, index) => ( + + {modal ? ( + + ) : null} + {children} + + ) + )} ); } diff --git a/src/components/__tests__/Dialog.test.tsx b/src/components/__tests__/Dialog.test.tsx index 0b9a9a7f39..64dc266946 100644 --- a/src/components/__tests__/Dialog.test.tsx +++ b/src/components/__tests__/Dialog.test.tsx @@ -1,10 +1,4 @@ -import { - Text, - StyleSheet, - Platform, - BackHandler as RNBackHandler, -} from 'react-native'; -import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; +import { Text, StyleSheet, Platform } from 'react-native'; import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { act, userEvent } from '@testing-library/react-native'; @@ -14,13 +8,6 @@ import { render, screen } from '../../test-utils'; import Button from '../Button/Button'; import Portal from '../Portal/Portal'; -interface BackHandlerStatic extends RNBackHandlerStatic { - mockPressBack(): void; -} - -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const BackHandler = RNBackHandler as BackHandlerStatic; - afterEach(() => { jest.restoreAllMocks(); }); @@ -87,9 +74,7 @@ describe('Dialog', () => { expect(onDismiss).toHaveBeenCalledTimes(0); }); - it('invokes onDismiss on Android back button press when only dismissableBackButton is true', async () => { - jest.replaceProperty(Platform, 'OS', 'android'); - + it("doesn't invoke onDismiss when the overlay is pressed and dismissableOverlay is false", async () => { const onDismiss = jest.fn(); await render( @@ -97,9 +82,8 @@ describe('Dialog', () => { This is simple dialog @@ -115,13 +99,6 @@ describe('Dialog', () => { }); expect(onDismiss).toHaveBeenCalledTimes(0); - - await act(() => { - BackHandler.mockPressBack(); - jest.runAllTimers(); - }); - - expect(onDismiss).toHaveBeenCalledTimes(1); }); it('applies top margin to the first child', async () => { @@ -175,12 +152,10 @@ describe('Dialog', () => { describe('DialogActions', () => { it('renders passed children', async () => { await render( - - - - - - + + + + ); expect(screen.getByTestId('button-cancel')).toBeOnTheScreen(); @@ -189,12 +164,10 @@ describe('DialogActions', () => { it('applies default styles', async () => { await render( - - - - - - + + + + ); const dialogActionsContainer = screen.getByTestId('dialog-actions'); @@ -210,12 +183,10 @@ describe('DialogActions', () => { it('applies 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 d3d4879440..6d9be49c47 100644 --- a/src/components/__tests__/Modal.test.tsx +++ b/src/components/__tests__/Modal.test.tsx @@ -3,8 +3,8 @@ import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; import { afterAll, + afterEach, beforeAll, - beforeEach, describe, expect, it, @@ -49,8 +49,8 @@ describe('Modal', () => { /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ }); - beforeEach(() => { - BackHandler.exitApp.mockClear(); + afterEach(() => { + jest.clearAllMocks(); }); it('renders passed children', async () => { @@ -318,7 +318,7 @@ describe('Modal', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('does not invoke onDismiss on Android back button press when dismissableBackButton is false', async () => { + it("doesn't invoke onDismiss when the overlay is pressed and dismissableOverlay is false", async () => { const onDismiss = jest.fn(); await render( @@ -328,27 +328,22 @@ describe('Modal', () => { overlayTestID="backdrop" visible onDismiss={onDismiss} - dismissableBackButton={false} + dismissableOverlay={false} > {null}
); - await act(() => { - BackHandler.mockPressBack(); - jest.runAllTimers(); - }); - - expect(onDismiss).not.toHaveBeenCalled(); - - await userEvent.press(screen.getByRole('button', { name: 'Close modal' })); + await userEvent.press( + screen.getByTestId('backdrop', { includeHiddenElements: true }) + ); await act(() => { jest.runAllTimers(); }); - expect(onDismiss).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); }); it('does not render the visually hidden dismiss button for a non-dismissible modal', async () => { @@ -524,7 +519,7 @@ describe('Modal', () => { expect(onDismiss).not.toHaveBeenCalled(); }); - it('absorbs the Android back button for a non-dismissible modal', async () => { + it("doesn't pass the Android back button press on from a non-dismissible modal", async () => { await render( diff --git a/src/components/__tests__/Portal.test.tsx b/src/components/__tests__/Portal.test.tsx index 64ff1121bb..2b9549eeb6 100644 --- a/src/components/__tests__/Portal.test.tsx +++ b/src/components/__tests__/Portal.test.tsx @@ -1,6 +1,18 @@ -import { Text } from 'react-native'; +import * as React from 'react'; +import { BackHandler as RNBackHandler, Platform, Text } from 'react-native'; +import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native'; -import { expect, it, jest } from '@jest/globals'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act, fireEvent } from '@testing-library/react-native'; import { LocaleProvider, useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; @@ -15,6 +27,14 @@ import Portal from '../Portal/Portal'; jest.useRealTimers(); +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; + it('renders portal with siblings', async () => { const { toJSON } = await render( @@ -232,3 +252,465 @@ it('makes the app content reachable again once the modal unmounts', async () => expect(screen.queryByText('modal content')).not.toBeOnTheScreen(); expect(screen.getByText('page content')).toBeVisible(); }); + +describe('onDismiss', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when the back button is pressed', () => { + const pressBack = async () => { + await act(() => { + BackHandler.mockPressBack(); + }); + }; + + it('closes only the topmost modal portal, then the one underneath on the next press', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + const Portals = ({ upperOpen }: { upperOpen: boolean }) => ( + + + lower + + {upperOpen ? ( + + upper + + ) : null} + + ); + + const { rerender } = await render(); + + await pressBack(); + + expect(onDismissUpper).toHaveBeenCalledTimes(1); + expect(onDismissLower).not.toHaveBeenCalled(); + + await rerender(); + + await pressBack(); + + expect(onDismissLower).toHaveBeenCalledTimes(1); + expect(onDismissUpper).toHaveBeenCalledTimes(1); + }); + + it('lets content inside a modal handle the press first, also after a modal above it closes', async () => { + const onBackInside = jest.fn(() => true); + const onDismissLower = jest.fn(); + + const BackListener = () => { + React.useEffect(() => { + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + onBackInside + ); + + return () => subscription.remove(); + }, []); + + return search; + }; + + const Portals = ({ upperOpen }: { upperOpen: boolean }) => ( + + + + + {upperOpen ? ( + {}}> + upper + + ) : null} + + ); + + const { rerender } = await render(); + + await pressBack(); + + expect(onBackInside).toHaveBeenCalledTimes(1); + + await rerender(); + await rerender(); + + await pressBack(); + + expect(onBackInside).toHaveBeenCalledTimes(2); + expect(onDismissLower).not.toHaveBeenCalled(); + }); + + it('picks the modal drawn on top, not the one that became modal last', async () => { + const onDismissFirst = jest.fn(); + const onDismissSecond = jest.fn(); + + const Portals = ({ firstOpen }: { firstOpen: boolean }) => ( + + + first + + + second + + + ); + + const { rerender } = await render(); + + await rerender(); + + await pressBack(); + + // Portals are drawn in the order they mounted, so the second one is on + // top even though the first became modal after it. + expect(onDismissSecond).toHaveBeenCalledTimes(1); + expect(onDismissFirst).not.toHaveBeenCalled(); + }); + + it('ignores portals above the modal that are not modal', async () => { + const onDismissModal = jest.fn(); + const onDismissPortal = jest.fn(); + + await render( + + + dialog + + + tooltip + + + ); + + await pressBack(); + + expect(onDismissModal).toHaveBeenCalledTimes(1); + expect(onDismissPortal).not.toHaveBeenCalled(); + }); + + it('gives the press to the modal underneath once the one above stops being modal', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + const Portals = ({ upperModal }: { upperModal: boolean }) => ( + + + lower + + + upper + + + ); + + const { rerender } = await render(); + + // E.g. a modal that is still fading out. + await rerender(); + + await pressBack(); + + expect(onDismissLower).toHaveBeenCalledTimes(1); + expect(onDismissUpper).not.toHaveBeenCalled(); + }); + + it('leaves the press alone when no modal is open', async () => { + const onDismiss = jest.fn(); + + await render( + + + tooltip + + + ); + + await pressBack(); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).toHaveBeenCalledTimes(1); + }); + + it("doesn't pass the press on from a modal without onDismiss", async () => { + const onDismissLower = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + await pressBack(); + + expect(onDismissLower).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).not.toHaveBeenCalled(); + }); + + it('stops listening once the host unmounts', async () => { + const onDismiss = jest.fn(); + + const { unmount } = await render( + + + dialog + + + ); + + await unmount(); + + await pressBack(); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(BackHandler.exitApp).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the Escape key is pressed on the web', () => { + type FakeKeyboardEvent = ReturnType; + type Listener = (event: FakeKeyboardEvent) => void; + + let platform: { restore(): void }; + let listeners = new Set(); + + beforeAll(() => { + platform = jest.replaceProperty(Platform, 'OS', 'web'); + + // There is no DOM under the React Native preset, and the portal host + // 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: Listener) => { + if (type === 'keydown') { + listeners.add(listener); + } + }, + removeEventListener: (type: string, listener: Listener) => { + if (type === 'keydown') { + listeners.delete(listener); + } + }, + }, + }); + }); + + afterAll(() => { + platform.restore(); + delete (global as { document?: unknown }).document; + }); + + beforeEach(() => { + listeners = new Set(); + }); + + const createEvent = ({ + isComposing = false, + }: { + isComposing?: boolean; + }) => { + const event = { + key: 'Escape', + isComposing, + defaultPrevented: false, + preventDefault: jest.fn(() => { + event.defaultPrevented = true; + }), + }; + + return event; + }; + + // Runs the key press the way a browser does: first the focused element + // inside the modal, then the listeners on `document` as it bubbles up. + const pressEscape = async ({ + onFocusedElementKeyDown, + ...options + }: { + onFocusedElementKeyDown?: (event: FakeKeyboardEvent) => void; + isComposing?: boolean; + } = {}) => { + const event = createEvent(options); + + await act(() => { + onFocusedElementKeyDown?.(event); + + for (const listener of listeners) { + listener(event); + } + }); + + return event; + }; + + it('closes only the topmost modal portal', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + const event = await pressEscape(); + + expect(onDismissUpper).toHaveBeenCalledTimes(1); + expect(onDismissLower).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('marks the key handled for a modal without onDismiss', async () => { + const onDismissLower = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + const event = await pressEscape(); + + expect(onDismissLower).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('stays open when an element inside the modal marks the key handled', async () => { + const onDismiss = jest.fn(); + const onFocusedElementKeyDown = jest.fn((event: FakeKeyboardEvent) => + event.preventDefault() + ); + + await render( + + + dialog + + + ); + + await pressEscape({ onFocusedElementKeyDown }); + + expect(onFocusedElementKeyDown).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('stays open while the user is composing text', async () => { + const onDismiss = jest.fn(); + + await render( + + + dialog + + + ); + + const event = await pressEscape({ isComposing: true }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('closes a Modal on Escape following dismissable, not dismissableOverlay', async () => { + const onDismissBlocked = jest.fn(); + const onDismissAllowed = jest.fn(); + + const { rerender } = await render( + + + blocked + + + ); + + await pressEscape(); + + expect(onDismissBlocked).not.toHaveBeenCalled(); + + await rerender( + + + allowed + + + ); + + await pressEscape(); + + expect(onDismissAllowed).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the VoiceOver escape gesture is made', () => { + const escapeFrom = async (text: string) => { + await fireEvent( + screen.getByText(text, { includeHiddenElements: true }), + 'accessibilityEscape' + ); + }; + + it('closes only the topmost modal portal', async () => { + const onDismissLower = jest.fn(); + const onDismissUpper = jest.fn(); + + await render( + + + lower + + + upper + + + ); + + await escapeFrom('lower'); + + expect(onDismissLower).not.toHaveBeenCalled(); + expect(onDismissUpper).not.toHaveBeenCalled(); + + await escapeFrom('upper'); + + expect(onDismissUpper).toHaveBeenCalledTimes(1); + expect(onDismissLower).not.toHaveBeenCalled(); + }); + + it("doesn't close a portal that is not modal", async () => { + const onDismiss = jest.fn(); + + await render( + + + popup + + + ); + + await escapeFrom('popup'); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap index 8cf23e1346..2625c341c2 100644 --- a/src/components/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Modal.test.tsx.snap @@ -212,7 +212,6 @@ exports[`Modal closes a non-dismissible modal when visible changes from true to exports[`Modal closes a non-dismissible modal when visible changes from true to false 2`] = ` <> 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 deleted file mode 100644 index cf84dddb8a..0000000000 --- a/src/utils/useOverlayDismiss.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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]); -}