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;