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 = "";
+ }
+}