"use client"; import { useEffect, useRef, type PointerEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; export type ModalProps = { open: boolean; title: ReactNode; onClose: () => void; children: ReactNode; /** 底部操作区(取消/主按钮等) */ footer?: ReactNode; /** 标题旁辅助说明 */ description?: ReactNode; /** 面板最大宽度 class,默认 max-w-md */ maxWidthClass?: string; /** 面板额外 class(如 max-h / flex) */ panelClassName?: string; /** * 点遮罩关闭。按下与抬起都必须落在遮罩上才会关: * 框内拖选后在遮罩松手、或在遮罩按下后拖进弹层松手,都不会关。 * 确认/扣费等仍建议关此开关。 */ dismissOnOverlay?: boolean; /** busy 时禁用关闭与 ESC */ busy?: boolean; /** 叠层高度,确认框用 1100,普通 1000 */ zIndexClass?: string; /** 无障碍:dialog | alertdialog */ role?: "dialog" | "alertdialog"; ariaLabel?: string; /** 自定义标题区(如带图标);提供时覆盖默认 title 行,仍渲染关闭钮除非 hideClose */ header?: ReactNode; hideClose?: boolean; /** 是否响应 ESC;默认 true(按键非误触)。仅封禁告知等必须点确认的场景设 false */ closeOnEscape?: boolean; /** 内容区额外 class */ bodyClassName?: string; }; /** * 全站弹层壳:统一遮罩、面板、标题关闭钮与 ESC。 * 默认:点遮罩不关;开启 dismissOnOverlay 时须按下+抬起都在遮罩上。 * ESC / 关闭钮 / 页脚按钮可关。主 CTA 用 btn-primary,危险操作用 btn-danger。 */ export default function Modal({ open, title, onClose, children, footer, description, maxWidthClass = "max-w-md", panelClassName = "", dismissOnOverlay = false, busy = false, zIndexClass = "z-[1000]", role = "dialog", ariaLabel, header, hideClose = false, closeOnEscape = true, bodyClassName = "", }: ModalProps) { const overlayPressedRef = useRef(false); useEffect(() => { overlayPressedRef.current = false; }, [open]); useEffect(() => { if (!open || !closeOnEscape) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !busy) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, busy, onClose, closeOnEscape]); if (!open) return null; const label = ariaLabel || (typeof title === "string" ? title : undefined); const onOverlayPointerDown = (e: PointerEvent) => { overlayPressedRef.current = e.button === 0 && e.target === e.currentTarget; }; const onOverlayPointerUp = (e: PointerEvent) => { const pressedOnOverlay = overlayPressedRef.current; overlayPressedRef.current = false; if (!dismissOnOverlay || busy) return; if (e.button !== 0) return; // 用 pointerup 的真实落点,不用 click:mousedown 在遮罩、mouseup 在面板时 // click 会合成到共同祖先(遮罩)上,造成拖进弹层仍关闭。 if (!pressedOnOverlay || e.target !== e.currentTarget) return; onClose(); }; const onOverlayPointerCancel = () => { overlayPressedRef.current = false; }; return createPortal(
e.stopPropagation()} > {header ? (
{header}
{!hideClose && ( )}
) : (

{title}

{description ? (
{description}
) : null}
{!hideClose && }
)}
{children}
{footer ? (
{footer}
) : null}
, document.body ); } function CloseButton({ onClick, disabled, }: { onClick: () => void; disabled?: boolean; }) { return ( ); }