From 56210d2104c43872541b401be91dbab268f9912c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:24:34 +0000 Subject: [PATCH 01/15] Initial plan From 0723392df3c4b98e81587faefda660123a337e35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:35:13 +0000 Subject: [PATCH 02/15] Update modal to use native dialog element with showModal() Co-authored-by: marlonmarcello <1956448+marlonmarcello@users.noreply.github.com> --- README.md | 4 +- package-lock.json | 4 +- .../components/modal-focus-bounds/index.tsx | 1 - .../modal-focus-bounds.module.scss | 5 -- .../modal-focus-bounds/modal-focus-bounds.tsx | 10 ---- .../modal-inner/modal-inner.module.scss | 8 ++- .../components/modal-inner/modal-inner.tsx | 51 +++++++------------ .../modal-visually-hidden/index.tsx | 1 - .../modal-visually-hidden.module.scss | 10 ---- .../modal-visually-hidden.tsx | 15 ------ src/lib/components/modal/modal.module.scss | 7 --- src/lib/components/modal/modal.tsx | 8 +-- 12 files changed, 30 insertions(+), 94 deletions(-) delete mode 100644 src/lib/components/modal-focus-bounds/index.tsx delete mode 100644 src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss delete mode 100644 src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx delete mode 100644 src/lib/components/modal-visually-hidden/index.tsx delete mode 100644 src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss delete mode 100644 src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx delete mode 100644 src/lib/components/modal/modal.module.scss diff --git a/README.md b/README.md index a4001c7..481e320 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,9 @@ You can override these as you see fit and as your setup allows. This component was built with focus accessibility best-practices at top-of-mind, and provides enough flexibility to allow you to create an accessible modal window. -### Focus loop +### Focus trap -There are hidden elements at the start and end of the modal component, which, on focus, shift the user's focus to either the end or start of the content, respectively. +The modal uses the native `` element with the `showModal()` method, which automatically traps focus within the dialog. This ensures keyboard navigation stays within the modal while it is open. ### Focus on close diff --git a/package-lock.json b/package-lock.json index baa2a9f..d011b85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wethegit/react-modal", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wethegit/react-modal", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "dependencies": { "@changesets/changelog-github": "~0.5.0", diff --git a/src/lib/components/modal-focus-bounds/index.tsx b/src/lib/components/modal-focus-bounds/index.tsx deleted file mode 100644 index e5f7b60..0000000 --- a/src/lib/components/modal-focus-bounds/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ModalFocusBounds } from "./modal-focus-bounds" diff --git a/src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss b/src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss deleted file mode 100644 index 8327c0a..0000000 --- a/src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.modalFocusBounds { - // This applies to the hidden element(s) that help manage user focus. - box-shadow: none; - outline: none; -} diff --git a/src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx b/src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx deleted file mode 100644 index 660dac0..0000000 --- a/src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { forwardRef } from "react" - -import styles from "./modal-focus-bounds.module.scss" - -export const ModalFocusBounds = forwardRef(function ModalFocusBounds( - _, - ref -) { - return
-}) diff --git a/src/lib/components/modal-inner/modal-inner.module.scss b/src/lib/components/modal-inner/modal-inner.module.scss index 2864462..94e207f 100644 --- a/src/lib/components/modal-inner/modal-inner.module.scss +++ b/src/lib/components/modal-inner/modal-inner.module.scss @@ -1,8 +1,12 @@ .modalInner { align-items: center; + background: transparent; + border: none; display: flex; - inset: 0; + height: 100%; justify-content: center; + max-height: 100%; + max-width: 100%; + padding: 0; width: 100%; - z-index: 1; } diff --git a/src/lib/components/modal-inner/modal-inner.tsx b/src/lib/components/modal-inner/modal-inner.tsx index ae3e826..75bc78a 100644 --- a/src/lib/components/modal-inner/modal-inner.tsx +++ b/src/lib/components/modal-inner/modal-inner.tsx @@ -2,12 +2,9 @@ import { useEffect, useRef } from "react" import { classnames } from "../../../utils/classnames" -import { ModalVisuallyHidden } from "../modal-visually-hidden" -import { ModalFocusBounds } from "../modal-focus-bounds" - import styles from "./modal-inner.module.scss" -export interface ModalInnerProps extends React.HTMLAttributes { +export interface ModalInnerProps extends React.HTMLAttributes { /** * The content of the modal. */ @@ -16,45 +13,33 @@ export interface ModalInnerProps extends React.HTMLAttributes { * The className of the modal. */ className?: string + /** + * Called when the cancel event fires (e.g. user presses Escape). + * The default browser close behaviour is always prevented so React state stays in control. + */ + onCancel?: React.ReactEventHandler } -export function ModalInner({ children, className, ...props }: ModalInnerProps) { - const modalRef = useRef(null) - const firstFocusableElement = useRef(null) - const lastFocusableElement = useRef(null) - - const focusStartingPosition = () => { - const element = firstFocusableElement.current - if (element) element.focus() - } - - const focusEndingPosition = () => { - const element = lastFocusableElement.current - if (element) element.focus() - } +export function ModalInner({ children, className, onCancel, ...props }: ModalInnerProps) { + const dialogRef = useRef(null) useEffect(() => { - focusStartingPosition() + dialogRef.current?.showModal() }, []) return ( -
{ + // Prevent the browser from closing the dialog so our React state + // remains in control. The useModal hook handles closing via Escape. + e.preventDefault() + onCancel?.(e) + }} > - - - - {children} - - - - -
+
) } diff --git a/src/lib/components/modal-visually-hidden/index.tsx b/src/lib/components/modal-visually-hidden/index.tsx deleted file mode 100644 index 2f2e5a0..0000000 --- a/src/lib/components/modal-visually-hidden/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ModalVisuallyHidden } from "./modal-visually-hidden" diff --git a/src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss b/src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss deleted file mode 100644 index 5205dac..0000000 --- a/src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -.modalVisuallyHidden { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} diff --git a/src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx b/src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx deleted file mode 100644 index 0edce2b..0000000 --- a/src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { forwardRef } from "react" - -import styles from "./modal-visually-hidden.module.scss" - -export interface ModalVisuallyHiddenProps { - onFocus?: () => void -} - -export const ModalVisuallyHidden = forwardRef( - function ModalVisuallyHidden(props: ModalVisuallyHiddenProps, ref) { - return ( -
- ) - } -) diff --git a/src/lib/components/modal/modal.module.scss b/src/lib/components/modal/modal.module.scss deleted file mode 100644 index 5c39867..0000000 --- a/src/lib/components/modal/modal.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.ModalFixed { - position: fixed; -} - -.ModalAbsolute { - position: absolute; -} diff --git a/src/lib/components/modal/modal.tsx b/src/lib/components/modal/modal.tsx index 1d978c5..5169701 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -3,9 +3,7 @@ import ReactDOM from "react-dom" import { ModalInner } from "../modal-inner" import type { ModalInnerProps } from "../modal-inner" -import { classnames } from "../../../utils/classnames" -import styles from "./modal.module.scss" export interface ModalProps extends ModalInnerProps { /** * The modal will be appended to the passed element instead of being rendered in place @@ -14,10 +12,8 @@ export interface ModalProps extends ModalInnerProps { renderTo: HTMLElement } -export function Modal({ renderTo, className, ...props }: ModalProps) { - const classes = classnames([styles.ModalFixed, className]) - - const modalContent = +export function Modal({ renderTo, ...props }: ModalProps) { + const modalContent = if (renderTo) { return ReactDOM.createPortal(modalContent, renderTo) From 812bdae050e94615e19ee8096e5b2ae6d4a523b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:24:40 +0000 Subject: [PATCH 03/15] Address native dialog review feedback --- src/lib/components/modal-inner/modal-inner.module.scss | 5 +++++ src/lib/hooks/use-modal.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/components/modal-inner/modal-inner.module.scss b/src/lib/components/modal-inner/modal-inner.module.scss index 94e207f..b75e37a 100644 --- a/src/lib/components/modal-inner/modal-inner.module.scss +++ b/src/lib/components/modal-inner/modal-inner.module.scss @@ -2,6 +2,7 @@ align-items: center; background: transparent; border: none; + color: inherit; display: flex; height: 100%; justify-content: center; @@ -10,3 +11,7 @@ padding: 0; width: 100%; } + +.modalInner::backdrop { + background: transparent; +} diff --git a/src/lib/hooks/use-modal.ts b/src/lib/hooks/use-modal.ts index e25ef76..4b98246 100644 --- a/src/lib/hooks/use-modal.ts +++ b/src/lib/hooks/use-modal.ts @@ -28,7 +28,7 @@ export function useModal(props: UseModalOptions = {}) { // an extra dependecy and stay within the render loop current = cur - if (cur !== ModalStates.CLOSED) return ModalStates.CLOSED + if (cur !== ModalStates.CLOSED) return ModalStates.CLOSED return cur }) @@ -41,6 +41,8 @@ export function useModal(props: UseModalOptions = {}) { window.history.replaceState({}, "", window.location.pathname) } + document.querySelector("dialog:modal")?.close() + if (triggerRef && triggerRef.current) triggerRef.current.focus() }, [triggerRef, hash]) From 643511f25e4015ac98e7c279f83cb9e4b0e8fd1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:39:30 +0000 Subject: [PATCH 04/15] Synchronize native dialog with modal state --- README.md | 16 +++++++++------- .../components/modal-inner/modal-inner.tsx | 19 +++++++++++++++---- src/lib/hooks/use-modal.ts | 13 ++++++++++--- src/main.tsx | 16 ++++++++++------ 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 481e320..70a69f3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ import "@wethegit/react-modal/style.css" ## Usage -The `useModal` hook provides mechanisms for hooking into the modal state. We'll need to pass said `state` to the `` component, as shown below. 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 `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. @@ -38,7 +38,7 @@ import { } from "@wethegit/react-modal" function MyModal() { - const { isOpen, toggle } = useModal() + const { isOpen, toggle, dialogRef } = useModal() return ( <> @@ -47,7 +47,7 @@ function MyModal() { {isOpen && ( - + @@ -73,7 +73,7 @@ import { } from "@wethegit/react-modal" function MyModal() { - const { isOpen, toggle } = useModal({ + const { isOpen, toggle, dialogRef } = useModal({ hash: "my-modal-hash" }) @@ -84,7 +84,7 @@ function MyModal() { {isOpen && ( - + @@ -117,7 +117,7 @@ function MyModal() { const triggerButton = useRef(null) const modalRootRef = useRef(null) - const { isOpen, toggle } = useModal({ + const { isOpen, toggle, dialogRef } = useModal({ // `triggerRef` allows the focus to shift to whatever triggered the modal triggerRef: triggerButton, // `hash` will automatically open the modal when hash changes and will also update the hash when the modal opens/closes @@ -137,7 +137,8 @@ function MyModal() {
{render && modalRootRef.current && ( - ` diff --git a/src/lib/components/modal-inner/modal-inner.tsx b/src/lib/components/modal-inner/modal-inner.tsx index 75bc78a..3c399f5 100644 --- a/src/lib/components/modal-inner/modal-inner.tsx +++ b/src/lib/components/modal-inner/modal-inner.tsx @@ -13,6 +13,10 @@ export interface ModalInnerProps extends React.HTMLAttributes * The className of the modal. */ className?: string + /** + * A ref to the native dialog element. + */ + dialogRef?: React.RefObject /** * Called when the cancel event fires (e.g. user presses Escape). * The default browser close behaviour is always prevented so React state stays in control. @@ -20,12 +24,19 @@ export interface ModalInnerProps extends React.HTMLAttributes onCancel?: React.ReactEventHandler } -export function ModalInner({ children, className, onCancel, ...props }: ModalInnerProps) { - const dialogRef = useRef(null) +export function ModalInner({ + children, + className, + dialogRef: forwardedDialogRef, + onCancel, + ...props +}: ModalInnerProps) { + const internalDialogRef = useRef(null) + const dialogRef = forwardedDialogRef ?? internalDialogRef useEffect(() => { - dialogRef.current?.showModal() - }, []) + if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() + }, [dialogRef]) return ( (ModalStates.CLOSED) + const dialogRef = useRef(null) const handleClose = useCallback(() => { let current: ModalStates | undefined @@ -41,7 +42,7 @@ export function useModal(props: UseModalOptions = {}) { window.history.replaceState({}, "", window.location.pathname) } - document.querySelector("dialog:modal")?.close() + if (dialogRef.current?.open) dialogRef.current.close() if (triggerRef && triggerRef.current) triggerRef.current.focus() }, [triggerRef, hash]) @@ -58,7 +59,12 @@ export function useModal(props: UseModalOptions = {}) { return cur }) - if (current === ModalStates.OPEN) return + if (current === ModalStates.OPEN) { + if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() + return + } + + if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() if (hash && window && window.location.hash !== `#${hash}`) { window.location.hash = `#${hash}` @@ -105,6 +111,7 @@ export function useModal(props: UseModalOptions = {}) { return { isOpen: state === ModalStates.OPEN, + dialogRef, toggle, } } diff --git a/src/main.tsx b/src/main.tsx index c71a5b2..17dd241 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,7 +9,7 @@ import styles from "./main.module.css" function CustomModal() { const triggerButton = useRef(null) const modalRootRef = useRef(null) - const { isOpen, toggle } = useModal({ + const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, }) @@ -22,7 +22,7 @@ function CustomModal() {
{isOpen && modalRootRef.current && ( - + {isOpen && bodyRef.current && ( - + {isOpen && ( - - + event.target === event.currentTarget && toggle()} + >

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -68,7 +69,6 @@ It's as easy as passing a `hash` prop to the `useModal` hook. The hook will then import { Modal, ModalContent, - ModalBackdrop, useModal } from "@wethegit/react-modal" @@ -84,8 +84,10 @@ function MyModal() { {isOpen && ( - - + event.target === event.currentTarget && toggle()} + >

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -109,7 +111,6 @@ import { useAnimatePresence } from '@wethegit/react-hooks' import { Modal, ModalContent, - ModalBackdrop, useModal } from "@wethegit/react-modal" @@ -140,11 +141,11 @@ function MyModal() { event.target === event.currentTarget && toggle()} style={{ transition: `opacity 800ms ease-in-out`, opacity: animate ? 1 : 0 }}> -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -57,12 +60,12 @@ function CustomModalOnBody() { {isOpen && bodyRef.current && ( event.target === event.currentTarget && toggle()} > - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -89,10 +92,14 @@ function ModalWithHash() {
{isOpen && modalRootRef.current && ( - - + event.target === event.currentTarget && toggle()} + > -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

From a9bdfa31142e5c87ac3761831be2334b21e6bb0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:16:18 +0000 Subject: [PATCH 08/15] Use dynamic viewport units for dialog sizing Co-authored-by: marlonmarcello <1956448+marlonmarcello@users.noreply.github.com> --- src/lib/components/modal/modal.module.scss | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/components/modal/modal.module.scss b/src/lib/components/modal/modal.module.scss index 3dcf666..0b18f6f 100644 --- a/src/lib/components/modal/modal.module.scss +++ b/src/lib/components/modal/modal.module.scss @@ -13,6 +13,15 @@ width: 100vw; } +@supports (height: 100dvh) and (width: 100dvw) { + .modal { + height: 100dvh; + max-height: 100dvh; + max-width: 100dvw; + width: 100dvw; + } +} + .modal:not([open]) { display: none; } From 85d8b0faa629ce878806257f20be487c3ae6293b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:20:20 +0000 Subject: [PATCH 09/15] Handle backdrop closing inside Modal Co-authored-by: marlonmarcello <1956448+marlonmarcello@users.noreply.github.com> --- README.md | 13 +++------- src/lib/components/modal/modal.tsx | 9 +++++++ src/lib/hooks/use-modal.ts | 39 +++++++++++++++++++++++------- src/main.tsx | 3 --- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7e1440d..76af0c8 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,7 @@ function MyModal() { {isOpen && ( - event.target === event.currentTarget && toggle()} - > +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -84,10 +81,7 @@ function MyModal() { {isOpen && ( - event.target === event.currentTarget && toggle()} - > +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -141,7 +135,6 @@ function MyModal() { event.target === event.currentTarget && toggle()} style={{ transition: `opacity 800ms ease-in-out`, opacity: animate ? 1 : 0 @@ -170,7 +163,7 @@ function MyModal() { | dialogRef | RefObject | null | The ref returned by `useModal`, used to associate the hook with this specific native dialog. | | children | ReactNode | null | | -The native dialog backdrop is styled with the `::backdrop` pseudo-element. Clicking outside the dialog content can be handled by checking whether the click event target is the dialog itself, as shown in the examples. +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 217e405..90cfcf9 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -23,6 +23,7 @@ export function Modal({ dialogRef: forwardedDialogRef, className, onCancel, + onClick, children, ...props }: ModalProps) { @@ -33,11 +34,19 @@ export function Modal({ if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() }, [dialogRef]) + const handleClick = (event: React.MouseEvent) => { + onClick?.(event) + if (event.target === event.currentTarget && !event.defaultPrevented) { + dialogRef.current?.close() + } + } + const modal = ( } {...props} + onClick={handleClick} onCancel={(event) => { event.preventDefault() onCancel?.(event) diff --git a/src/lib/hooks/use-modal.ts b/src/lib/hooks/use-modal.ts index 1741f4a..d30a9b8 100644 --- a/src/lib/hooks/use-modal.ts +++ b/src/lib/hooks/use-modal.ts @@ -19,11 +19,12 @@ export interface UseModalOptions { export function useModal(props: UseModalOptions = {}) { const { triggerRef, hash } = props const [state, setState] = useState(ModalStates.CLOSED) + const stateRef = useRef(state) const dialogRef = useRef(null) - const handleClose = useCallback(() => { - if (state === ModalStates.CLOSED) return - + const closeState = useCallback(() => { + if (stateRef.current === ModalStates.CLOSED) return + stateRef.current = ModalStates.CLOSED setState(ModalStates.CLOSED) if (hash && window && window.location.hash === `#${hash}`) { @@ -31,21 +32,29 @@ export function useModal(props: UseModalOptions = {}) { window.history.replaceState({}, "", window.location.pathname) } - if (dialogRef.current?.open) dialogRef.current.close() - if (triggerRef && triggerRef.current) triggerRef.current.focus() - }, [triggerRef, hash, state]) + }, [triggerRef, hash]) + + const handleClose = useCallback(() => { + if (stateRef.current === ModalStates.CLOSED) return + + if (dialogRef.current?.open) dialogRef.current.close() + closeState() + }, [closeState]) const handleOpen = useCallback(() => { - const wasOpen = state === ModalStates.OPEN - if (!wasOpen) setState(ModalStates.OPEN) + const wasOpen = stateRef.current === ModalStates.OPEN + if (!wasOpen) { + stateRef.current = ModalStates.OPEN + setState(ModalStates.OPEN) + } if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() if (!wasOpen && hash && window.location.hash !== `#${hash}`) { window.location.hash = `#${hash}` } - }, [hash, state]) + }, [hash]) const toggle = useCallback(() => { // check all states in case user tried to close the modal in a transition @@ -72,6 +81,18 @@ export function useModal(props: UseModalOptions = {}) { } }, [handleClose, handleOpen, hash]) + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + const handleDialogClose = () => { + if (!dialog.open) closeState() + } + + dialog.addEventListener("close", handleDialogClose) + return () => dialog.removeEventListener("close", handleDialogClose) + }, [closeState]) + // Hook up the escape key useEffect(() => { const onKeyUp = (e: KeyboardEvent) => { diff --git a/src/main.tsx b/src/main.tsx index 23e9a03..ed64156 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -25,7 +25,6 @@ function CustomModal() { renderTo={modalRootRef.current} dialogRef={dialogRef} className={styles.CustomModalOverlay} - onClick={(event) => event.target === event.currentTarget && toggle()} >
- {render && modalRootRef.current && ( + {isOpen && modalRootRef.current && ( + >
diff --git a/src/lib/hooks/use-modal.ts b/src/lib/hooks/use-modal.ts index adb3517..9ffacf0 100644 --- a/src/lib/hooks/use-modal.ts +++ b/src/lib/hooks/use-modal.ts @@ -93,19 +93,6 @@ export function useModal(props: UseModalOptions = {}) { } }, [handleClose, handleOpen, hash]) - // Hook up the escape key - useEffect(() => { - const onKeyUp = (e: KeyboardEvent) => { - if (e.code === "Escape") handleClose() - } - - window.addEventListener("keyup", onKeyUp) - - return () => { - window.removeEventListener("keyup", onKeyUp) - } - }, [handleClose]) - return { isOpen: state === ModalStates.OPEN, dialogRef, From 42b1fb60748ccb3040fd124e82ade586072f08d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:24:51 +0000 Subject: [PATCH 14/15] Require ModalContent for backdrop handling --- README.md | 2 +- src/lib/components/modal/modal.tsx | 41 ++++++------------------------ 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index c86ee76..1f2947e 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ The native dialog backdrop is styled with the `::backdrop` pseudo-element. Click ### `` -A wrapper for the modal's content. This component is optional, but it's recommended to use it for styling purposes. +A required wrapper for the modal's content. It also defines the content boundary used to distinguish backdrop interactions from clicks inside the modal. Any valid `
` props can be passed to this component. diff --git a/src/lib/components/modal/modal.tsx b/src/lib/components/modal/modal.tsx index c452856..b507e9f 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -3,10 +3,12 @@ import { useEffect, useRef } from "react" import ReactDOM from "react-dom" import { classnames } from "../../../utils/classnames" +import { ModalContent, type ModalContentProps } from "../modal-content/modal-content" import styles from "./modal.module.scss" -export interface ModalProps extends React.DialogHTMLAttributes { +export interface ModalProps + extends Omit, "children"> { /** * The modal will be appended to the passed element instead of being rendered in place * @defaultValue defaults inPlace @@ -16,6 +18,7 @@ export interface ModalProps extends React.DialogHTMLAttributes } export function Modal({ @@ -41,38 +44,10 @@ export function Modal({ if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() }, [dialogRef, renderTo]) - const isOutsideContent = (event: React.PointerEvent) => { - const { currentTarget, clientX, clientY } = event - - for (const child of Array.from(currentTarget.childNodes)) { - let rects: DOMRect[] - - if (child.nodeType === Node.TEXT_NODE) { - if (!child.textContent?.trim()) continue - const range = document.createRange() - range.selectNode(child) - rects = Array.from(range.getClientRects()) - } else if (child instanceof Element) { - rects = [child.getBoundingClientRect()] - } else { - continue - } - - if ( - rects.some( - (rect) => - clientX >= rect.left && - clientX <= rect.right && - clientY >= rect.top && - clientY <= rect.bottom - ) - ) { - return false - } - } - - return true - } + const isOutsideContent = (event: React.PointerEvent) => + !event.nativeEvent + .composedPath() + .includes(event.currentTarget.firstElementChild as Element) const handleClick = (event: React.MouseEvent) => { onClick?.(event) From 5215b326956b19600d2ba9f2e3903fb7046fdcbd Mon Sep 17 00:00:00 2001 From: "Marlon U. Marcello" Date: Fri, 23 Oct 2026 13:10:58 -0700 Subject: [PATCH 15/15] fix: Require native event path to include dialog before closing parent Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Marlon U. Marcello --- src/lib/components/modal/modal.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/components/modal/modal.tsx b/src/lib/components/modal/modal.tsx index b507e9f..3067843 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -44,10 +44,13 @@ export function Modal({ if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() }, [dialogRef, renderTo]) - const isOutsideContent = (event: React.PointerEvent) => - !event.nativeEvent - .composedPath() - .includes(event.currentTarget.firstElementChild as Element) + const isOutsideContent = (event: React.PointerEvent) => { + const path = event.nativeEvent.composedPath() + return ( + path.includes(event.currentTarget) && + !path.includes(event.currentTarget.firstElementChild as Element) + ) + } const handleClick = (event: React.MouseEvent) => { onClick?.(event)