From 9b8269dd7ee0902a730a6b99cd5791dbe7b26060 Mon Sep 17 00:00:00 2001 From: Konstantin Marushchak Date: Mon, 14 Sep 2026 15:53:01 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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 + * + * * ); * }; *