Files
jiang13-bbs/frontend/components/Modal.tsx
freefire 5f7193042f feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 04:16:32 +08:00

177 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
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<HTMLDivElement>) => {
overlayPressedRef.current =
e.button === 0 && e.target === e.currentTarget;
};
const onOverlayPointerUp = (e: PointerEvent<HTMLDivElement>) => {
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 (
<div
className={`fixed inset-0 ${zIndexClass} flex items-center justify-center p-4`}
style={{ background: "rgba(0,0,0,0.55)" }}
onPointerDown={onOverlayPointerDown}
onPointerUp={onOverlayPointerUp}
onPointerCancel={onOverlayPointerCancel}
role={role}
aria-modal="true"
aria-label={label}
>
<div
className={`w-full ${maxWidthClass} rounded-2xl j13-toast-in ${panelClassName || "p-6"}`}
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
onClick={(e) => e.stopPropagation()}
>
{header ? (
<div className="flex items-start justify-between gap-3 mb-4">
<div className="min-w-0 flex-1">{header}</div>
{!hideClose && (
<CloseButton onClick={onClose} disabled={busy} />
)}
</div>
) : (
<div className="flex items-start justify-between gap-3 mb-4">
<div className="min-w-0 flex-1">
<h2
className="text-[16px] sm:text-[18px] font-extrabold tracking-tight leading-snug"
style={{ color: "var(--ink)" }}
>
{title}
</h2>
{description ? (
<div className="mt-1 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{description}
</div>
) : null}
</div>
{!hideClose && <CloseButton onClick={onClose} disabled={busy} />}
</div>
)}
<div className={bodyClassName}>{children}</div>
{footer ? (
<div className="flex items-center justify-end gap-2 mt-5">{footer}</div>
) : null}
</div>
</div>
);
}
function CloseButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label="关闭"
className="w-7 h-7 shrink-0 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors disabled:opacity-50"
style={{ color: "var(--ink-3)" }}
>
<X size={15} />
</button>
);
}