refactor(core): share modal portal logic

This commit is contained in:
2026-07-09 12:58:49 +08:00
parent 11f187fce4
commit 3f1e4a0936
3 changed files with 125 additions and 73 deletions
+13 -36
View File
@@ -11,9 +11,9 @@
* - 简单 slide-up 动画(与原 Dart 行为接近) * - 简单 slide-up 动画(与原 Dart 行为接近)
* - 调用方只关心内容布局 * - 调用方只关心内容布局
*/ */
import { type ReactNode, useEffect } from "react"; import { type ReactNode } from "react";
import { createPortal } from "react-dom";
import { ModalPortal } from "./modal-portal";
import styles from "./bottom-sheet.module.css"; import styles from "./bottom-sheet.module.css";
export interface BottomSheetProps { export interface BottomSheetProps {
@@ -36,40 +36,17 @@ export function BottomSheet({
persistent = false, persistent = false,
ariaLabel, ariaLabel,
}: BottomSheetProps) { }: BottomSheetProps) {
useEffect(() => { return (
if (!open || typeof document === "undefined") return; <ModalPortal
const onKey = (e: KeyboardEvent) => { open={open}
if (e.key === "Escape" && !persistent) onClose(); onClose={onClose}
}; scrimClassName={styles.scrim}
document.addEventListener("keydown", onKey); panelClassName={styles.panel}
const prevOverflow = document.body.style.overflow; scrimOpacity={scrimOpacity}
document.body.style.overflow = "hidden"; persistent={persistent}
return () => { ariaLabel={ariaLabel}
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose, persistent]);
if (!open || typeof document === "undefined") return null;
return createPortal(
<div
className={styles.scrim}
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
onClick={(e) => {
if (!persistent && e.target === e.currentTarget) onClose();
}}
> >
<div {children}
role="dialog" </ModalPortal>
aria-modal="true"
aria-label={ariaLabel}
className={styles.panel}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body,
); );
} }
+14 -37
View File
@@ -11,9 +11,9 @@
* *
* 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。 * 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
*/ */
import { type ReactNode, useEffect } from "react"; import { type ReactNode } from "react";
import { createPortal } from "react-dom";
import { ModalPortal } from "./modal-portal";
import styles from "./dialog.module.css"; import styles from "./dialog.module.css";
export interface DialogProps { export interface DialogProps {
@@ -39,41 +39,18 @@ export function Dialog({
persistent = false, persistent = false,
ariaLabel, ariaLabel,
}: DialogProps) { }: DialogProps) {
useEffect(() => { return (
if (!open || typeof document === "undefined") return; <ModalPortal
const onKey = (e: KeyboardEvent) => { open={open}
if (e.key === "Escape" && !persistent) onClose(); onClose={onClose}
}; scrimClassName={styles.scrim}
document.addEventListener("keydown", onKey); panelClassName={styles.panel}
const prevOverflow = document.body.style.overflow; panelStyle={{ maxWidth }}
document.body.style.overflow = "hidden"; scrimOpacity={scrimOpacity}
return () => { persistent={persistent}
document.removeEventListener("keydown", onKey); ariaLabel={ariaLabel}
document.body.style.overflow = prevOverflow;
};
}, [open, onClose, persistent]);
if (!open || typeof document === "undefined") return null;
return createPortal(
<div
className={styles.scrim}
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
onClick={(e) => {
if (!persistent && e.target === e.currentTarget) onClose();
}}
> >
<div {children}
role="dialog" </ModalPortal>
aria-modal="true"
aria-label={ariaLabel}
className={styles.panel}
style={{ maxWidth }}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body,
); );
} }
+98
View File
@@ -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<HTMLDivElement>) => {
if (!persistent && event.target === event.currentTarget) onClose();
};
return createPortal(
<div
className={scrimClassName}
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
onClick={handleScrimClick}
>
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className={panelClassName}
style={panelStyle}
onClick={(event) => event.stopPropagation()}
>
{children}
</div>
</div>,
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 = "";
}
}