Modal 弹窗组件
更新时间:2023-02-22 15:57:53标签:reacttypescriptweb前端
效果
点击按钮
示例代码
Typescript
1import { FC, useRef } from 'react';2import { ModalInstance, PortalModal } from '@shared/components/modal';34const ModalExample: FC = () => {56 const modalRef = useRef<ModalInstance>(null);78 return (9 <>10 <button onClick={() => modalRef.current?.show()}>弹出Modal</button>11 <PortalModal12 ref={modalRef}13 header={14 <h2 style={{ margin: 0 }}>Modal 标题</h2>15 }16 footer={17 <button onClick={() => modalRef.current?.hide()}>关闭</button>18 }19 >20 {21 new Array(10).fill(1).map((_, k) => {22 return (23 <p key={k}>Modal 内容</p>24 );25 })26 }27 </PortalModal>28 </>29 );30};3132export { ModalExample };
组件实现
Typescript
1import React, {2 forwardRef,3 PropsWithChildren,4 ReactNode,5 useCallback,6 useImperativeHandle,7 useMemo,8 useRef,9 useState,10 MouseEvent,11 useEffect,12} from 'react';13import { Token, combineClass } from '@shared/utils';14import { CSSTransition } from 'react-transition-group';15import { createPortal } from 'react-dom';16import styles from './modal.module.scss';17import { Icon } from '../icons';1819const DURATION = 300;2021export interface ModalInstance {22 show(): Promise<string>;23 hide(): Promise<string>;24 toggle(): Promise<string>;25 state(): boolean;26}27type ModalProps = PropsWithChildren<{28 header?: ReactNode;29 footer?: ReactNode;30 contentClassName?: string;31 afterClose?: () => void;32 maskClosable?: boolean;33}>;34export const Modal = forwardRef<ModalInstance, ModalProps>((props, ref) => {3536 const { children, header, contentClassName, footer, afterClose, maskClosable = true } = props;3738 const wrapperRef = useRef<HTMLDivElement>(null);39 const maskRef = useRef<HTMLDivElement>(null);40 const [show, setShow] = useState(false);41 const entered = useRef<Token<string>>();42 const exited = useRef<Token<string>>();4344 const onEntered = useCallback(() => {45 entered.current?.resolve('entered');46 }, []);47 const onExited = useCallback(() => {48 exited.current?.resolve('exited');49 }, []);50 const instance = useMemo<ModalInstance>(() => {51 return {52 state: () => show,53 show() {54 setShow(true);55 entered.current = new Token();56 return entered.current.promise;57 },58 hide() {59 setShow(false);60 exited.current = new Token();61 return exited.current.promise;62 },63 toggle() {64 const result = !show;65 setShow(result);66 if (result) {67 entered.current = new Token();68 return entered.current.promise;69 }70 exited.current = new Token();71 return exited.current.promise;72 },73 };74 }, [show]);75 const preventClick = useCallback((e: MouseEvent<HTMLDivElement>) => {76 e.stopPropagation();77 }, []);78 const handleClose = useCallback(() => {79 instance.hide().then(afterClose);80 }, [afterClose, instance]);8182 useEffect(() => {83 if (show) {84 document.body.classList.add(styles['disable-scroll']);85 } else {86 document.body.classList.remove(styles['disable-scroll']);87 }88 return () => document.body.classList.remove(styles['disable-scroll']);89 }, [show]);9091 useImperativeHandle(ref, () => {92 return instance;93 });9495 return (96 <>97 <CSSTransition98 in={show}99 classNames={{100 enter: styles['lemon-modal-fade-enter'],101 enterActive: styles['lemon-modal-fade-enter-active'],102 exit: styles['lemon-modal-fade-exit'],103 exitActive: styles['lemon-modal-fade-exit-active'],104 exitDone: styles['lemon-modal-fade-exit-done'],105 }}106 timeout={DURATION}107 onEntered={onEntered}108 onExited={onExited}109 unmountOnExit110 nodeRef={maskRef}111 >112 <div ref={maskRef} className={styles['lemon-modal-mask']} />113 </CSSTransition>114 <CSSTransition115 in={show}116 classNames={{117 enter: styles['lemon-modal-enter'],118 enterActive: styles['lemon-modal-enter-active'],119 exit: styles['lemon-modal-exit'],120 exitActive: styles['lemon-modal-exit-active'],121 exitDone: styles['lemon-modal-exit-done'],122 }}123 timeout={DURATION}124 onEntered={onEntered}125 onExited={onExited}126 unmountOnExit127 nodeRef={wrapperRef}128 >129 <div130 ref={wrapperRef}131 className={styles['lemon-modal-wrapper']}132 onClick={() => maskClosable ? handleClose() : null}133 >134 <div className={styles['lemon-modal']} onClick={preventClick}>135 {136 header !== null && (137 <div className={styles['lemon-modal-header']}>138 <div className={styles['header-content']}>{header}</div>139 <Icon className={styles['close']} type={'close'} onClick={handleClose} />140 </div>141 )142 }143 <div className={combineClass(styles['lemon-modal-content'], contentClassName)}>{children}</div>144 <div className={styles['lemon-modal-footer']}>{footer}</div>145 </div>146 </div>147 </CSSTransition>148 </>149 );150});151152Modal.displayName = 'Modal';153154export const PortalModal = forwardRef<ModalInstance, ModalProps>((props, ref) => {155156 const [container, setContainer] = useState<HTMLDivElement | undefined>();157158 useEffect(() => {159 const dom = document.createElement('div');160 dom.className = 'lemon-portal-modal';161 document.body.appendChild(dom);162 setContainer(dom);163 return () => {164 document.body.removeChild(dom);165 };166 }, []);167168 if (container) {169 return createPortal(170 <Modal { ...props } ref={ref} />,171 container,172 );173 }174175 return null;176});177178PortalModal.displayName = 'PortalModal';
CSS
1$duration: 300ms;2$zIndex: 999;34.lemon-modal-enter {5}6.lemon-modal-enter-active {7}8.lemon-modal-exit {9 opacity: 1;10 .lemon-modal{11 transform: scale(1);12 }13}14.lemon-modal-exit-active {15 transition: opacity $duration ease;16 opacity: 0;17 .lemon-modal{18 transform: scale(0);19 transition: transform $duration ease;20 }21}22@keyframes lemon-modal-eject {23 0%{24 transform: scale(0);25 opacity: 0;26 }27 60%{28 transform: scale(1.1);29 }30 100%{31 transform: scale(1);32 opacity: 1;33 }34}35.lemon-modal-fade-enter {36 opacity: 0;37}38.lemon-modal-fade-enter-active {39 opacity: 1;40 transition: opacity $duration;41}42.lemon-modal-fade-exit {43 opacity: 1;44}45.lemon-modal-fade-exit-active {46 opacity: 0;47 transition: opacity $duration;48}49.lemon-modal-mask{50 position: fixed;51 top: 0;52 left: 0;53 right: 0;54 bottom: 0;55 z-index: $zIndex;56 background: rgba(0, 0, 0, .5);57}58.lemon-modal-wrapper{59 position: fixed;60 z-index: $zIndex;61 overflow: auto;62 top: 0;63 left: 0;64 right: 0;65 bottom: 0;66 line-height: 100vh;67 text-align: center;68}69.lemon-modal{70 display: inline-block;71 vertical-align: middle;72 line-height: initial;73 text-align: initial;74 border-radius: 8px;75 width: 500px;76 max-width: 80%;77 background: #fff;78 font-size: 1rem;79 animation: lemon-modal-eject ease $duration;80}81.lemon-modal-header{82 padding: 20px 20px 10px 20px;83 display: flex;84 align-items: center;85 .header-content{86 flex: 1;87 width: calc(100% - 24px);88 }89 .close{90 font-size: 1.4em;91 &:hover{92 cursor: pointer;93 opacity: .9;94 }95 }96}97.lemon-modal-content{98 padding: 15px;99}100.lemon-modal-footer{101 padding: 0 15px 15px 15px;102}103.disable-scroll{104 overflow: hidden !important;105}