Kaynağa Gözat

添加通用弹窗组件

fogwind 2 hafta önce
ebeveyn
işleme
1fd7d425f1

+ 45 - 0
src/app/globals.css

@@ -216,4 +216,49 @@ body {
   .ly-input{
     @apply block w-full box-border text-ly-12 p-4 border-1 border-ly-inputborder h-11.5 placeholder:text-ly-placeholder
   }
+
+  .fade-in{
+    animation: fade-in 0.3s ease-in-out forwards;
+  }
+  .fade-out{
+    animation: fade-out 0.3s ease-in-out forwards;
+  }
+  @keyframes fade-in{
+    0%{
+      opacity: 0;
+    }
+    100%{
+      opacity: 1;
+    }
+  }
+  @keyframes fade-out{
+    0%{
+      opacity: 1;
+    }
+    100%{
+      opacity: 0;
+    }
+  }
+  .slide-bottom-in{
+    animation: slide-bottom-in 0.3s ease-in-out forwards;
+  }
+  .slide-bottom-out{
+    animation: slide-bottom-out 0.3s ease-in-out forwards;
+  }
+  @keyframes slide-bottom-in{
+    0%{
+      transform: translateY(100%);
+    }
+    100%{
+      transform: translateY(0);
+    }
+  }
+  @keyframes slide-bottom-out{
+    0%{
+      transform: translateY(0%);
+    }
+    100%{
+      transform: translateY(100%);
+    }
+  }
 }

+ 10 - 2
src/components/Portal.tsx

@@ -27,7 +27,7 @@ export default function Portal({ children }: { children: React.ReactNode }) {
 但是在react v19 中 认为mounted是一个「派生状态(derived state)」,
 完全可以在 render 时计算出来(使用document 来判断),不需要专门声明一个state在副作用里更改状态(引起二次渲染),所以eslint报错。
 */
-
+import {useRef, useEffect} from 'react';
 import { createPortal } from 'react-dom';
 
 export default function Portal({
@@ -35,7 +35,15 @@ export default function Portal({
 }: {
   children: React.ReactNode;
 }) {
-  if (typeof window === 'undefined') {
+  const mountedRef = useRef(false);
+
+  useEffect(() => {
+    mountedRef.current = true;
+    // 无需任何清理 (cleanup) 函数
+  }, []); // 依赖数组完全为空
+
+  // 服务端和客户端首次渲染时都返回 null
+  if (!mountedRef.current) {
     return null;
   }
 

+ 134 - 0
src/components/theme/ui/CommonModal.tsx

@@ -0,0 +1,134 @@
+"use client";
+
+import clsx from "clsx";
+import { useState, useLayoutEffect, useRef } from "react";
+import Portal from "@/components/Portal";
+
+/**
+ * 为了弹窗里的表单校验开发整个组件:
+ * 当弹窗关闭,校验弹窗里的表单时要保证弹窗里的表单元素还存在文档流中,所以这个组件采用display block/none 来控制弹窗的显示和隐藏 
+ */
+export default function CommonModal({ 
+    isOpen,
+    contentClassName,
+    clickOutsideClose,
+    onClose,
+    header,
+    body,
+    footer
+}: {
+    isOpen: boolean;
+    contentClassName?: string;
+    clickOutsideClose?: boolean;
+    onClose: (e: boolean) => void;
+    header: React.ReactNode;
+    body: React.ReactNode;
+    footer: React.ReactNode;
+}) {
+    // show - 显示
+    // outing - 隐藏动画中
+    // hidden - 隐藏
+    const [rootVisible, setRootVisible] = useState<'show' | 'outing' | 'hidden'>('hidden');
+    
+    const cssInit = useRef(false);
+    const rootRef = useRef<HTMLDivElement>(null);
+    const footerRef = useRef<HTMLDivElement>(null);
+    const headerRef = useRef<HTMLDivElement>(null);
+ 
+    const [rootPddingBottom, setRootPddingBottom] = useState(0);
+    const [rootPddingTop, setRootPddingTop] = useState(0);
+
+    const baseClassNname = "absolute box-border w-full bg-white transition-transform duration-300 ease-out";
+    let applyClassName = "bottom-0 left-0 h-17/20";
+    if(contentClassName) {
+        applyClassName = contentClassName;
+    }
+
+    useLayoutEffect(() => {
+        const original = document.documentElement.style.overflow;
+        if(isOpen) {
+            document.documentElement.style.overflow = "hidden";
+        } else {
+            document.documentElement.style.overflow = "";
+        }
+
+        if(isOpen) {
+            setRootVisible('show');
+        } else {
+            if(cssInit.current) {
+                setRootVisible('outing');
+            }
+            
+        }
+
+        return () => { document.documentElement.style.overflow = original; };
+    },[isOpen]);
+    // 当弹窗完全显示后计算 footer 高度
+    useLayoutEffect(() => {
+        if (rootVisible === 'show' && !cssInit.current && footerRef.current && headerRef.current) {
+            const footerHeight = footerRef.current.getBoundingClientRect().height;
+            const headerHeight = headerRef.current.getBoundingClientRect().height;
+            setRootPddingBottom(footerHeight);
+            setRootPddingTop(headerHeight);
+            cssInit.current = true;
+        }
+    }, [rootVisible]);
+    const rootClick = (e: React.MouseEvent) => {
+        e.stopPropagation();
+        if(clickOutsideClose) {
+            onClose(false);
+        }  
+    }
+    // 如果是关闭,动画结束之后才隐藏
+    const handlerAnimationEnd = (e: React.AnimationEvent<HTMLDivElement>) => {
+        if(e.animationName === "fade-out") {
+            setRootVisible('hidden');
+        } 
+        
+    }
+    // 初始化时是隐藏状态
+    // fade-in的时候display block + fade-in
+    // fade-out的时候需要先加fade-out 类名,动画效果结束之后才能display none 
+    return (
+        <Portal>
+            <div 
+                ref={rootRef} 
+                className={clsx("w-full h-full backdrop-blur-md backdrop-saturate-150 bg-black/30 fixed inset-0 z-50 transition-all ease-in-out",{
+                    "block fade-in": rootVisible === "show",
+                    "hidden": rootVisible === "hidden",
+                    "fade-out": rootVisible === "outing"
+                })}
+                onAnimationEnd={handlerAnimationEnd}
+                onClick={rootClick}
+            >
+                <div 
+                    className={clsx(baseClassNname,applyClassName,
+                        {
+                            "slide-bottom-in": rootVisible === "show",
+                            "slide-bottom-out": rootVisible === "outing",
+                        }
+                    )}
+                    style={{
+                        paddingTop: rootPddingTop,
+                        paddingBottom: rootPddingBottom
+                    }}
+                >
+                    <div ref={headerRef} className="w-full box-border absolute left-0 top-0">
+                        {header}
+                    </div>
+                    
+                    <div className="w-full box-border h-full overflow-y-auto relative">
+                        
+                        {body}
+                        
+                    </div>
+
+                    <div ref={footerRef} className="w-full box-border absolute bottom-0 left-0 border-t border-gray-200">
+                        {footer}
+                    </div>
+                </div>
+                
+            </div>
+        </Portal>
+    ); 
+}