From 8ec1fb79580fd2769b25b72aed5cdc0c2e538c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Thu, 24 Sep 2026 22:23:42 +0200 Subject: [PATCH] feat: add keepMounted prop Keep the modal mounted in the DOM when it is closed so the state of the content inside it is preserved. The modal is still animated out and hidden once the closing animation finishes using a delayed visibility transition, then revealed again on open. While hidden the focus trap is unmounted so focus is restored on close and set again on open, and scroll lock, esc handling and overlay clicks stay disabled since the modal is no longer open. Closes #233 --- .../__tests__/index.test.tsx | 136 ++++++++++++++++++ react-responsive-modal/src/index.tsx | 26 +++- website/src/components/ExampleRendered.tsx | 2 + website/src/docs/index.mdx | 11 ++ website/src/examples/KeepMounted.tsx | 25 ++++ 5 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 website/src/examples/KeepMounted.tsx diff --git a/react-responsive-modal/__tests__/index.test.tsx b/react-responsive-modal/__tests__/index.test.tsx index ed59be32..2517690a 100644 --- a/react-responsive-modal/__tests__/index.test.tsx +++ b/react-responsive-modal/__tests__/index.test.tsx @@ -827,6 +827,142 @@ describe('modal', () => { }); }); + describe('prop: keepMounted', () => { + it('should keep the modal mounted and hidden when closed', async () => { + const { getByTestId, rerender } = render( + null} keepMounted animationDuration={0.01}> +
modal content
+
, + ); + + rerender( + null} + keepMounted + animationDuration={0.01} + > +
modal content
+
, + ); + + expect(getByTestId('modal')).toBeInTheDocument(); + expect(getByTestId('root')).toHaveStyle({ visibility: 'hidden' }); + }); + + it('should show the modal again when reopened', async () => { + const { getByTestId, rerender } = render( + null} keepMounted animationDuration={0.01}> +
modal content
+
, + ); + + rerender( + null} + keepMounted + animationDuration={0.01} + > +
modal content
+
, + ); + expect(getByTestId('root')).toHaveStyle({ visibility: 'hidden' }); + + rerender( + null} keepMounted animationDuration={0.01}> +
modal content
+
, + ); + + expect(getByTestId('modal')).toBeInTheDocument(); + expect(getByTestId('root')).not.toHaveStyle({ visibility: 'hidden' }); + }); + + it('should preserve the DOM state of the modal content', async () => { + const { getByTestId, rerender } = render( + null} keepMounted animationDuration={0.01}> + + , + ); + + fireEvent.change(getByTestId('input'), { target: { value: 'hello' } }); + + rerender( + null} + keepMounted + animationDuration={0.01} + > + + , + ); + + rerender( + null} keepMounted animationDuration={0.01}> + + , + ); + + expect(getByTestId('input')).toHaveValue('hello'); + }); + + it('should call onAnimationEnd without unmounting when closed', async () => { + const onAnimationEnd = vitest.fn(); + const { getByTestId, rerender } = render( + null} + onAnimationEnd={onAnimationEnd} + keepMounted + animationDuration={0.01} + > +
modal content
+
, + ); + + rerender( + null} + onAnimationEnd={onAnimationEnd} + keepMounted + animationDuration={0.01} + > +
modal content
+
, + ); + fireEvent.animationEnd(getByTestId('modal')); + + expect(onAnimationEnd).toHaveBeenCalledTimes(1); + expect(getByTestId('modal')).toBeInTheDocument(); + }); + + it('should not close on esc when the hidden modal is kept mounted', async () => { + const onClose = vitest.fn(); + const { rerender } = render( + +
modal content
+
, + ); + + rerender( + +
modal content
+
, + ); + + fireEvent.keyDown(document, { keyCode: 27 }); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + describe('prop: containerId', () => { it('should renders container div with id', async () => { const containerId = 'container-id'; diff --git a/react-responsive-modal/src/index.tsx b/react-responsive-modal/src/index.tsx index 15d761eb..f4c9196a 100644 --- a/react-responsive-modal/src/index.tsx +++ b/react-responsive-modal/src/index.tsx @@ -165,6 +165,13 @@ export interface ModalProps { * Callback fired when the Modal has exited and the animation is finished. */ onAnimationEnd?: (event: React.AnimationEvent) => void; + /** + * Keep the modal mounted in the DOM when it is closed. Useful to preserve + * the state of the content inside the modal. + * + * Default to false. + */ + keepMounted?: boolean; children?: React.ReactNode; } @@ -195,6 +202,7 @@ export const Modal = React.forwardRef( onEscKeyDown, onOverlayClick, onAnimationEnd, + keepMounted = false, children, reserveScrollBarGap, dir, @@ -282,11 +290,11 @@ export const Modal = React.forwardRef( useEffect(() => { // If the open prop is changing, we need to open the modal // This is also called on the first render if the open prop is true when the modal is created - if (open && !showPortal) { + if ((open || keepMounted) && !showPortal) { setShowPortal(true); handleOpen(); } - }, [open]); + }, [open, keepMounted, showPortal]); const handleClickOverlay = ( event: React.MouseEvent, @@ -321,7 +329,7 @@ export const Modal = React.forwardRef( return; } - if (!open) { + if (!open && !keepMounted) { setShowPortal(false); } @@ -342,7 +350,15 @@ export const Modal = React.forwardRef( ? createPortal(
@@ -387,7 +403,7 @@ export const Modal = React.forwardRef( data-testid="modal" tabIndex={-1} > - {focusTrapped && ( + {focusTrapped && (open || !keepMounted) && ( React.ReactElement> = { longContent: LongContent, focusTrapped: FocusTrapped, focusTrappedInitialFocus: FocusTrappedInitialFocus, + keepMounted: KeepMounted, customCssStyle: CustomCssStyle, customAnimation: CustomAnimation, customCloseIcon: CustomCloseIcon, diff --git a/website/src/docs/index.mdx b/website/src/docs/index.mdx index 52065c70..1e57720d 100644 --- a/website/src/docs/index.mdx +++ b/website/src/docs/index.mdx @@ -153,6 +153,16 @@ By default, the Modal will be rendered at the end of the html body tag. If you w ``` +### Keeping the modal mounted + +By default the Modal is unmounted from the DOM when it is closed. Set the `keepMounted` prop to keep it mounted while closed, so the state of the content inside the Modal is preserved. The Modal is hidden once the closing animation finishes, until it is opened again. + + + +```js file=../examples/KeepMounted.tsx + +``` + ## Accessibility - Follow the [ARIA best practices](https://www.w3.org/TR/wai-aria-practices/#dialog_modal) by using either: @@ -220,6 +230,7 @@ By default, the Modal will be rendered at the end of the html body tag. If you w | **onEscKeyDown\*** | `(event: KeyboardEvent) => void` | | Callback fired when the escape key is pressed. | | **onOverlayClick\*** | `(event: React.MouseEvent) => void` | | Callback fired when the overlay is clicked. | | **onAnimationEnd\*** | `(event: React.AnimationEvent) => void` | | Callback fired when the Modal has exited and the animation is finished. | +| **keepMounted** | `boolean` | false | Keep the modal mounted in the DOM when it is closed. Useful to preserve the state of the content inside the modal. | ## License diff --git a/website/src/examples/KeepMounted.tsx b/website/src/examples/KeepMounted.tsx new file mode 100644 index 00000000..52ead098 --- /dev/null +++ b/website/src/examples/KeepMounted.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Modal } from 'react-responsive-modal'; + +const App = () => { + const [open, setOpen] = React.useState(false); + + return ( + <> + + + setOpen(false)} center keepMounted> +

The modal is kept mounted

+

+ Type something in the input below, close the modal and reopen it. The + value is preserved because the modal is not unmounted when closed. +

+ +
+ + ); +}; + +export default App;