CommonModal.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. "use client";
  2. import clsx from "clsx";
  3. import { useState, useLayoutEffect, useRef } from "react";
  4. import Portal from "@/components/common/portal/Portal";
  5. /**
  6. * 为了弹窗里的表单校验开发整个组件:
  7. * 当弹窗关闭,校验弹窗里的表单时要保证弹窗里的表单元素还存在文档流中,所以这个组件采用display block/none 来控制弹窗的显示和隐藏
  8. */
  9. export default function CommonModal({
  10. isOpen,
  11. destroyOnClose = false,
  12. contentClassName,
  13. clickOutsideClose,
  14. onClose,
  15. header,
  16. body,
  17. footer
  18. }: {
  19. isOpen: boolean;
  20. destroyOnClose?: boolean;
  21. contentClassName?: string;
  22. clickOutsideClose?: boolean;
  23. onClose: (e: boolean) => void;
  24. header?: React.ReactNode;
  25. body: React.ReactNode;
  26. footer?: React.ReactNode;
  27. }) {
  28. // show - 显示
  29. // outing - 隐藏动画中
  30. // hidden - 隐藏
  31. const [rootVisible, setRootVisible] = useState<'show' | 'outing' | 'hidden'>('hidden');
  32. const [mounted, setMounted] = useState(false);
  33. const cssInit = useRef(false);
  34. const rootRef = useRef<HTMLDivElement>(null);
  35. const footerRef = useRef<HTMLDivElement>(null);
  36. const headerRef = useRef<HTMLDivElement>(null);
  37. const [rootPddingBottom, setRootPddingBottom] = useState(0);
  38. const [rootPddingTop, setRootPddingTop] = useState(0);
  39. const baseClassNname = "absolute box-border transition-transform duration-300 ease-out";
  40. let applyClassName = "bottom-0 left-0 h-17/20 w-full bg-white rounded-t-xl";
  41. if(contentClassName) {
  42. applyClassName = contentClassName;
  43. }
  44. useLayoutEffect(() => {
  45. const original = document.documentElement.style.overflow;
  46. if(isOpen){
  47. // destroy模式重新创建DOM
  48. if(destroyOnClose){
  49. // eslint-disable-next-line react-hooks/set-state-in-effect
  50. setMounted(true);
  51. }
  52. document.documentElement.style.overflow = "hidden";
  53. requestAnimationFrame(()=>{
  54. setRootVisible("show");
  55. });
  56. }else{
  57. document.documentElement.style.overflow = "";
  58. if(destroyOnClose){
  59. // 先播放关闭动画
  60. if(mounted){
  61. // eslint-disable-next-line react-hooks/set-state-in-effect
  62. setRootVisible("outing");
  63. }
  64. }else{
  65. // 保留DOM
  66. if(cssInit.current){
  67. // eslint-disable-next-line react-hooks/set-state-in-effect
  68. setRootVisible("outing");
  69. }
  70. }
  71. }
  72. return ()=>{ document.documentElement.style.overflow = original; };
  73. },[isOpen, destroyOnClose, mounted]);
  74. // useLayoutEffect(() => {
  75. // const original = document.documentElement.style.overflow;
  76. // if(isOpen) {
  77. // document.documentElement.style.overflow = "hidden";
  78. // } else {
  79. // document.documentElement.style.overflow = "";
  80. // }
  81. // // intent: 为了实现关闭时也有动画效果,告诉eslint忽略这个错误
  82. // if(isOpen) {
  83. // // eslint-disable-next-line react-hooks/set-state-in-effect
  84. // setRootVisible('show');
  85. // } else {
  86. // if(cssInit.current) {
  87. // // eslint-disable-next-line react-hooks/set-state-in-effect
  88. // setRootVisible('outing');
  89. // }
  90. // }
  91. // return () => { document.documentElement.style.overflow = original; };
  92. // },[isOpen]);
  93. // 当弹窗完全显示后计算 footer 高度
  94. useLayoutEffect(() => {
  95. if (rootVisible === 'show' && !cssInit.current && footerRef.current && headerRef.current) {
  96. const footerHeight = footerRef.current.getBoundingClientRect().height;
  97. const headerHeight = headerRef.current.getBoundingClientRect().height;
  98. setRootPddingBottom(footerHeight);
  99. setRootPddingTop(headerHeight);
  100. cssInit.current = true;
  101. }
  102. }, [rootVisible]);
  103. const rootClick = (e: React.MouseEvent) => {
  104. e.stopPropagation();
  105. if(clickOutsideClose) {
  106. onClose(false);
  107. }
  108. }
  109. // 如果是关闭,动画结束之后才隐藏
  110. const handlerAnimationEnd = (e: React.AnimationEvent<HTMLDivElement>) => {
  111. if(e.animationName === "fade-out") {
  112. setRootVisible('hidden');
  113. if(destroyOnClose){
  114. setMounted(false);
  115. cssInit.current=false;
  116. }
  117. }
  118. }
  119. // 初始化时是隐藏状态
  120. // fade-in的时候display block + fade-in
  121. // fade-out的时候需要先加fade-out 类名,动画效果结束之后才能display none
  122. if(destroyOnClose && !mounted){
  123. return null;
  124. }
  125. return (
  126. <Portal>
  127. <div
  128. ref={rootRef}
  129. 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",{
  130. "block fade-in": rootVisible === "show",
  131. "hidden": rootVisible === "hidden",
  132. "fade-out": rootVisible === "outing"
  133. })}
  134. onAnimationEnd={handlerAnimationEnd}
  135. onClick={rootClick}
  136. >
  137. <div
  138. className={clsx(baseClassNname,applyClassName,
  139. {
  140. "slide-bottom-in": rootVisible === "show",
  141. "slide-bottom-out": rootVisible === "outing",
  142. }
  143. )}
  144. style={{
  145. paddingTop: rootPddingTop,
  146. paddingBottom: rootPddingBottom
  147. }}
  148. >
  149. <div ref={headerRef} className="w-full box-border absolute left-0 top-0">
  150. {header && header}
  151. </div>
  152. <div className="w-full box-border h-full overflow-y-auto relative">
  153. {body}
  154. </div>
  155. <div ref={footerRef} className={clsx("w-full box-border absolute bottom-0 left-0",{
  156. "border-t border-gray-200": footer !==null && footer !== undefined
  157. })}>
  158. {footer && footer}
  159. </div>
  160. </div>
  161. </div>
  162. </Portal>
  163. );
  164. }