80 lines
2.2 KiB
TypeScript
80 lines
2.2 KiB
TypeScript
"use client";
|
||
/**
|
||
* 通用 Dialog 基础组件(headless + 简单样式)
|
||
*
|
||
* 原始 Dart: 散落在各处的 `showDialog` + `Dialog` widget(quota_dialog, pwa_install_dialog,
|
||
* username_dialog, pronouns_dialog, subscription_dialog, environment_dialog, auth_other_options_dialog,
|
||
* facebook_login_dialog, external_browser_dialog)。
|
||
*
|
||
* 设计目标:~70 行覆盖所有 9 种 dialog 的通用逻辑(portal, ESC, scroll lock, click-outside),
|
||
* 调用方只关心内容布局。
|
||
*
|
||
* 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
|
||
*/
|
||
import { type ReactNode, useEffect } from "react";
|
||
import { createPortal } from "react-dom";
|
||
|
||
import styles from "./dialog.module.css";
|
||
|
||
export interface DialogProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
children: ReactNode;
|
||
/** 内容最大宽度(px)。默认 360。 */
|
||
maxWidth?: number;
|
||
/** 蒙层透明度(0-1)。默认 0.5。 */
|
||
scrimOpacity?: number;
|
||
/** 禁用点击外部 / ESC 关闭。 */
|
||
persistent?: boolean;
|
||
/** ARIA 标签。 */
|
||
ariaLabel?: string;
|
||
}
|
||
|
||
export function Dialog({
|
||
open,
|
||
onClose,
|
||
children,
|
||
maxWidth = 360,
|
||
scrimOpacity = 0.5,
|
||
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()}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|