diff --git a/.changeset/proud-chairs-behave.md b/.changeset/proud-chairs-behave.md new file mode 100644 index 0000000..f2aebf0 --- /dev/null +++ b/.changeset/proud-chairs-behave.md @@ -0,0 +1,5 @@ +--- +"@wethegit/react-modal": minor +--- + +Add native CSS entry and exit transitions diff --git a/.stylelintrc b/.stylelintrc index 34563f5..5bf836c 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -8,6 +8,7 @@ ], "plugins": ["stylelint-selector-bem-pattern"], "rules": { + "at-rule-no-unknown": [true, { "ignoreAtRules": ["starting-style"] }], "no-descending-specificity": null, "selector-class-pattern": null, "plugin/selector-bem-pattern": { diff --git a/README.md b/README.md index 1f2947e..10250c1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ import "@wethegit/react-modal/style.css" The `useModal` hook provides mechanisms for hooking into the modal state. Pass its `dialogRef` to `` so the hook can control that specific dialog's native modality. This approach allows us to abstract away state management, which becomes especially useful when [using a URL hash to manage the modal's state](#using-a-url-hash-to-manage-the-modal-state). -The snippet below demonstrates the **minimum** required setup. No focus management, no animations. +The snippet below demonstrates the **minimum** required setup. Keep `` mounted and pass `isOpen` so the native dialog can close without being unmounted. ```jsx import { @@ -45,14 +45,12 @@ function MyModal() { Open the modal window! - {isOpen && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } @@ -80,14 +78,12 @@ function MyModal() { Open the modal window! - {isOpen && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } @@ -97,7 +93,7 @@ function MyModal() { Focus management, rendering to a custom element, and hash-based state management. -**Important:** Animation with `useAnimatePresence` is not included in this refactor. Support is deferred to [#155](https://github.com/wethegit/react-modal/issues/155). Render the modal conditionally with `isOpen`, without delaying unmount for an exit animation. +The [`@starting-style`](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style) CSS at-rule lets you define entry animations with pure CSS. Keep `` mounted and pass `isOpen` to let the native dialog close normally; in supporting browsers, CSS can animate its exit using discrete transitions for `display` and `overlay`. ```jsx import { useRef } from 'react' @@ -107,6 +103,8 @@ import { useModal } from "@wethegit/react-modal" +import styles from "./my-styles.module.css" + function MyModal() { const triggerButton = useRef(null) const modalRootRef = useRef(null) @@ -125,24 +123,65 @@ function MyModal() {
- {isOpen && modalRootRef.current && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } ``` +```css +/* my-styles.module.css */ +.modalOverlay::backdrop { + background: rgb(0 0 0 / 80%); +} + +.modalOverlay { + opacity: 0; + transition: + opacity 0.5s ease, + display 0.5s allow-discrete, + overlay 0.5s allow-discrete; +} + +.modalOverlay[open] { + opacity: 1; +} + +.modal { + transition: opacity 0.5s ease, transform 0.5s ease; + opacity: 1; + transform: scale(1); +} + +.modalOverlay:not([open]) .modal { + opacity: 0; + transform: scale(0.9); +} + +@starting-style { + .modalOverlay[open] { + opacity: 0; + } + + .modal { + opacity: 0; + transform: scale(0.9); + } +} +``` + ## Props ### `` @@ -152,6 +191,7 @@ function MyModal() { | renderTo | HTMLElement | null | If a valid HTMLElement is provided, the modal will be appended to that element. Otherwise will be rendered in place. | | className | String | null | | | dialogRef | RefObject | null | The ref returned by `useModal`, used to associate the hook with this specific native dialog. | +| isOpen | Boolean | — | Controls the native dialog's open state. Keep `` mounted to allow CSS exit transitions. | | children | ReactNode | null | | The native dialog backdrop is styled with the `::backdrop` pseudo-element. Clicking the backdrop closes the modal automatically. diff --git a/src/lib/components/modal/modal.tsx b/src/lib/components/modal/modal.tsx index 3067843..fcf2fcc 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -13,7 +13,11 @@ export interface ModalProps * The modal will be appended to the passed element instead of being rendered in place * @defaultValue defaults inPlace **/ - renderTo: HTMLElement + renderTo?: HTMLElement + /** + * Controls whether the native dialog is open. When omitted, the dialog opens on mount. + */ + isOpen?: boolean /** * A ref to the native dialog element. */ @@ -23,6 +27,7 @@ export interface ModalProps export function Modal({ renderTo, + isOpen, dialogRef: forwardedDialogRef, className, onCancel, @@ -41,8 +46,15 @@ export function Modal({ const dialogRef = forwardedDialogRef ?? internalDialogRef useEffect(() => { - if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() - }, [dialogRef, renderTo]) + const dialog = dialogRef.current + if (!dialog) return + + if (isOpen === false) { + if (dialog.open) dialog.close() + } else if (!dialog.open) { + dialog.showModal() + } + }, [dialogRef, isOpen, renderTo]) const isOutsideContent = (event: React.PointerEvent) => { const path = event.nativeEvent.composedPath() diff --git a/src/main.module.css b/src/main.module.css index f4ede5a..f279a7e 100644 --- a/src/main.module.css +++ b/src/main.module.css @@ -1,5 +1,20 @@ .CustomModalOverlay::backdrop { background-color: rgb(125 125 125 / 80%); + transition: background-color 0.5s ease; +} + +.CustomModalOverlay { + opacity: 0; + transition: opacity 0.5s ease, display 0.5s allow-discrete, overlay 0.5s allow-discrete; +} + +.CustomModalOverlay[open] { + opacity: 1; +} + +.CustomModalOverlay:not([open]) .CustomModalTransition { + opacity: 0; + transform: scale(0); } .CustomModalContent { @@ -10,3 +25,18 @@ padding: 2rem; font-family: sans-serif; } + +.CustomModalTransition { + transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.77, 0, 0.1, 1.39); + transform: scale(1); +} + +@starting-style { + .CustomModalOverlay[open] { + opacity: 0; + } + + .CustomModalTransition { + transform: scale(0); + } +} diff --git a/src/main.tsx b/src/main.tsx index ed64156..84e7d2f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from "react" +import React, { useRef, useState } from "react" import { createRoot } from "react-dom/client" import { Modal, ModalContent, useModal } from "./lib" @@ -7,7 +7,7 @@ import styles from "./main.module.css" function CustomModal() { const triggerButton = useRef(null) - const modalRootRef = useRef(null) + const [modalRoot, setModalRoot] = useState(null) const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, }) @@ -18,37 +18,31 @@ function CustomModal() { -
+
- {isOpen && modalRootRef.current && ( - + - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+ +
) } function CustomModalOnBody() { const triggerButton = useRef(null) - const bodyRef = useRef(null) const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, }) - useEffect(() => { - bodyRef.current = document.body - }, []) - return ( <> {/* `triggerRef` allows the focus to shift to whatever triggered the modal, on close. */} @@ -56,27 +50,24 @@ function CustomModalOnBody() { Open the modal (Append to body) - {isOpen && bodyRef.current && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } function ModalWithHash() { const triggerButton = useRef(null) - const modalRootRef = useRef(null) + const [modalRoot, setModalRoot] = useState(null) const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, hash: "modal-with-hash", @@ -87,22 +78,19 @@ function ModalWithHash() { -
+
- {isOpen && modalRootRef.current && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) }