CommonModal.tsx 6.9 KB

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