refactor(core): share modal portal logic
This commit is contained in:
@@ -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(
|
||||
<div
|
||||
className={styles.scrim}
|
||||
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
|
||||
onClick={(e) => {
|
||||
if (!persistent && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className={styles.panel}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
return (
|
||||
<ModalPortal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
scrimClassName={styles.scrim}
|
||||
panelClassName={styles.panel}
|
||||
scrimOpacity={scrimOpacity}
|
||||
persistent={persistent}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<div
|
||||
className={styles.scrim}
|
||||
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
|
||||
onClick={(e) => {
|
||||
if (!persistent && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className={styles.panel}
|
||||
style={{ maxWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
return (
|
||||
<ModalPortal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
scrimClassName={styles.scrim}
|
||||
panelClassName={styles.panel}
|
||||
panelStyle={{ maxWidth }}
|
||||
scrimOpacity={scrimOpacity}
|
||||
persistent={persistent}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user