"use client"; /** * 通用底部弹层(Bottom Sheet) * * * * 设计目标: * - portal 渲染到 body * - 固定底部、最大宽度 500px、顶部圆角 `--radius-bottom-sheet` * - ESC 关闭 / 点击 scrim 关闭 / 打开时锁定 body 滚动 * - 简单 slide-up 动画(与原 Dart 行为接近) * - 调用方只关心内容布局 */ import { type ReactNode, useEffect } from "react"; import { createPortal } from "react-dom"; import styles from "./bottom-sheet.module.css"; export interface BottomSheetProps { open: boolean; onClose: () => void; children: ReactNode; /** 蒙层透明度(0-1)。默认 0.45。 */ scrimOpacity?: number; /** 禁用点击外部 / ESC 关闭。 */ persistent?: boolean; /** ARIA 标签。 */ ariaLabel?: string; } export function BottomSheet({ open, onClose, children, scrimOpacity = 0.45, 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(
{ if (!persistent && e.target === e.currentTarget) onClose(); }} >
e.stopPropagation()} > {children}
, document.body, ); }