Modal 弹窗组件

更新时间:2023-02-22 15:57:53标签:reacttypescriptweb前端

效果

点击按钮

示例代码

Typescript
1import { FC, useRef } from 'react';
2import { ModalInstance, PortalModal } from '@shared/components/modal';
3
4const ModalExample: FC = () => {
5
6 const modalRef = useRef<ModalInstance>(null);
7
8 return (
9 <>
10 <button onClick={() => modalRef.current?.show()}>弹出Modal</button>
11 <PortalModal
12 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};
31
32export { 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';
18
19const DURATION = 300;
20
21export 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) => {
35
36 const { children, header, contentClassName, footer, afterClose, maskClosable = true } = props;
37
38 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>>();
43
44 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]);
81
82 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]);
90
91 useImperativeHandle(ref, () => {
92 return instance;
93 });
94
95 return (
96 <>
97 <CSSTransition
98 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 unmountOnExit
110 nodeRef={maskRef}
111 >
112 <div ref={maskRef} className={styles['lemon-modal-mask']} />
113 </CSSTransition>
114 <CSSTransition
115 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 unmountOnExit
127 nodeRef={wrapperRef}
128 >
129 <div
130 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});
151
152Modal.displayName = 'Modal';
153
154export const PortalModal = forwardRef<ModalInstance, ModalProps>((props, ref) => {
155
156 const [container, setContainer] = useState<HTMLDivElement | undefined>();
157
158 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 }, []);
167
168 if (container) {
169 return createPortal(
170 <Modal { ...props } ref={ref} />,
171 container,
172 );
173 }
174
175 return null;
176});
177
178PortalModal.displayName = 'PortalModal';
CSS
1$duration: 300ms;
2$zIndex: 999;
3
4.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}