modal-shell.tsx 1.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import { createPortal } from "react-dom";
  4. import { cn } from "@/lib/utils";
  5. export function ModalShell({
  6. open,
  7. children,
  8. className,
  9. zIndexClassName = "z-[60]",
  10. onBackdropClick,
  11. /** 内容偏高时从顶部留白展示,避免卡片内滚动 */
  12. alignTop = false,
  13. }: {
  14. open: boolean;
  15. children: React.ReactNode;
  16. className?: string;
  17. zIndexClassName?: string;
  18. /** 点击遮罩时触发(子内容已阻止冒泡) */
  19. onBackdropClick?: () => void;
  20. alignTop?: boolean;
  21. }) {
  22. const [mounted, setMounted] = useState(false);
  23. useEffect(() => {
  24. /* eslint-disable-next-line react-hooks/set-state-in-effect -- document.body portal only after mount; avoids SSR mismatch */
  25. setMounted(true);
  26. }, []);
  27. if (!mounted) {
  28. return null;
  29. }
  30. return createPortal(
  31. <div
  32. role="presentation"
  33. className={cn(
  34. "fixed inset-0 flex justify-center px-4 transition-all duration-300",
  35. alignTop ? "items-start overflow-y-auto py-6 sm:py-10" : "items-center",
  36. zIndexClassName,
  37. open
  38. ? "pointer-events-auto bg-slate-900/55 opacity-100 backdrop-blur-[2px]"
  39. : "pointer-events-none bg-transparent opacity-0",
  40. )}
  41. onClick={
  42. onBackdropClick && open
  43. ? () => {
  44. onBackdropClick();
  45. }
  46. : undefined
  47. }
  48. >
  49. <div
  50. className={cn(
  51. "mx-auto w-full transition-all duration-300",
  52. open ? "translate-y-0 scale-100 opacity-100" : "translate-y-3 scale-[0.98] opacity-0",
  53. className,
  54. )}
  55. onClick={(e) => e.stopPropagation()}
  56. >
  57. {children}
  58. </div>
  59. </div>,
  60. document.body,
  61. );
  62. }