CommonModal.tsx 7.0 KB

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