diff --git a/src/app/_components/core/bottom-sheet.tsx b/src/app/_components/core/bottom-sheet.tsx index 813e6d05..2c6e029f 100644 --- a/src/app/_components/core/bottom-sheet.tsx +++ b/src/app/_components/core/bottom-sheet.tsx @@ -11,9 +11,9 @@ * - 简单 slide-up 动画(与原 Dart 行为接近) * - 调用方只关心内容布局 */ -import { type ReactNode, useEffect } from "react"; -import { createPortal } from "react-dom"; +import { type ReactNode } from "react"; +import { ModalPortal } from "./modal-portal"; import styles from "./bottom-sheet.module.css"; export interface BottomSheetProps { @@ -36,40 +36,17 @@ export function BottomSheet({ persistent = false, ariaLabel, }: BottomSheetProps) { - useEffect(() => { - if (!open || typeof document === "undefined") return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !persistent) onClose(); - }; - document.addEventListener("keydown", onKey); - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.removeEventListener("keydown", onKey); - document.body.style.overflow = prevOverflow; - }; - }, [open, onClose, persistent]); - - if (!open || typeof document === "undefined") return null; - - return createPortal( -
{ - if (!persistent && e.target === e.currentTarget) onClose(); - }} + return ( + -
e.stopPropagation()} - > - {children} -
-
, - document.body, + {children} + ); } diff --git a/src/app/_components/core/dialog.tsx b/src/app/_components/core/dialog.tsx index e6db488b..eeb4c6e3 100644 --- a/src/app/_components/core/dialog.tsx +++ b/src/app/_components/core/dialog.tsx @@ -11,9 +11,9 @@ * * 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。 */ -import { type ReactNode, useEffect } from "react"; -import { createPortal } from "react-dom"; +import { type ReactNode } from "react"; +import { ModalPortal } from "./modal-portal"; import styles from "./dialog.module.css"; export interface DialogProps { @@ -39,41 +39,18 @@ export function Dialog({ persistent = false, ariaLabel, }: DialogProps) { - useEffect(() => { - if (!open || typeof document === "undefined") return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !persistent) onClose(); - }; - document.addEventListener("keydown", onKey); - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.removeEventListener("keydown", onKey); - document.body.style.overflow = prevOverflow; - }; - }, [open, onClose, persistent]); - - if (!open || typeof document === "undefined") return null; - - return createPortal( -
{ - if (!persistent && e.target === e.currentTarget) onClose(); - }} + return ( + -
e.stopPropagation()} - > - {children} -
-
, - document.body, + {children} + ); } diff --git a/src/app/_components/core/modal-portal.tsx b/src/app/_components/core/modal-portal.tsx new file mode 100644 index 00000000..f4fd20d6 --- /dev/null +++ b/src/app/_components/core/modal-portal.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { + type CSSProperties, + type MouseEvent, + type ReactNode, + useEffect, +} from "react"; +import { createPortal } from "react-dom"; + +let bodyScrollLockDepth = 0; +let previousBodyOverflow = ""; + +export interface ModalPortalProps { + open: boolean; + onClose: () => void; + children: ReactNode; + scrimClassName: string; + panelClassName: string; + scrimOpacity: number; + panelStyle?: CSSProperties; + persistent?: boolean; + ariaLabel?: string; +} + +export function ModalPortal({ + open, + onClose, + children, + scrimClassName, + panelClassName, + scrimOpacity, + panelStyle, + persistent = false, + ariaLabel, +}: ModalPortalProps) { + useEffect(() => { + if (!open || typeof document === "undefined") return; + + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !persistent) onClose(); + }; + + document.addEventListener("keydown", onKey); + lockBodyScroll(); + + return () => { + document.removeEventListener("keydown", onKey); + unlockBodyScroll(); + }; + }, [open, onClose, persistent]); + + if (!open || typeof document === "undefined") return null; + + const handleScrimClick = (event: MouseEvent) => { + if (!persistent && event.target === event.currentTarget) onClose(); + }; + + return createPortal( +
+
event.stopPropagation()} + > + {children} +
+
, + document.body, + ); +} + +function lockBodyScroll(): void { + if (typeof document === "undefined") return; + + if (bodyScrollLockDepth === 0) { + previousBodyOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } + bodyScrollLockDepth += 1; +} + +function unlockBodyScroll(): void { + if (typeof document === "undefined") return; + + bodyScrollLockDepth = Math.max(0, bodyScrollLockDepth - 1); + if (bodyScrollLockDepth === 0) { + document.body.style.overflow = previousBodyOverflow; + previousBodyOverflow = ""; + } +}