1 | import React, { useEffect, useState, useRef } from 'react';
|
2 | import ReactDom from 'react-dom';
|
3 | import cx from 'classnames';
|
4 | import CloseIcon from './CloseIcon';
|
5 | import { FocusTrap } from './FocusTrap';
|
6 | import modalManager from './modalManager';
|
7 | import { isBrowser, blockNoScroll, unblockNoScroll } from './utils';
|
8 |
|
9 | const classes = {
|
10 | overlay: 'react-responsive-modal-overlay',
|
11 | modal: 'react-responsive-modal-modal',
|
12 | modalCenter: 'react-responsive-modal-modalCenter',
|
13 | closeButton: 'react-responsive-modal-closeButton',
|
14 | animationIn: 'react-responsive-modal-fadeIn',
|
15 | animationOut: 'react-responsive-modal-fadeOut',
|
16 | };
|
17 |
|
18 | export interface ModalProps {
|
19 | |
20 |
|
21 |
|
22 | open: boolean;
|
23 | |
24 |
|
25 |
|
26 |
|
27 |
|
28 | center?: boolean;
|
29 | |
30 |
|
31 |
|
32 |
|
33 |
|
34 | closeOnEsc?: boolean;
|
35 | |
36 |
|
37 |
|
38 |
|
39 |
|
40 | closeOnOverlayClick?: boolean;
|
41 | |
42 |
|
43 |
|
44 |
|
45 |
|
46 | blockScroll?: boolean;
|
47 | |
48 |
|
49 |
|
50 | showCloseIcon?: boolean;
|
51 | |
52 |
|
53 |
|
54 | closeIconId?: string;
|
55 | |
56 |
|
57 |
|
58 | closeIcon?: React.ReactNode;
|
59 | |
60 |
|
61 |
|
62 |
|
63 |
|
64 | focusTrapped?: boolean;
|
65 | |
66 |
|
67 |
|
68 |
|
69 |
|
70 | container?: Element;
|
71 | |
72 |
|
73 |
|
74 | classNames?: {
|
75 | overlay?: string;
|
76 | modal?: string;
|
77 | closeButton?: string;
|
78 | closeIcon?: string;
|
79 | animationIn?: string;
|
80 | animationOut?: string;
|
81 | };
|
82 | |
83 |
|
84 |
|
85 | styles?: {
|
86 | overlay?: React.CSSProperties;
|
87 | modal?: React.CSSProperties;
|
88 | closeButton?: React.CSSProperties;
|
89 | closeIcon?: React.CSSProperties;
|
90 | };
|
91 | |
92 |
|
93 |
|
94 |
|
95 |
|
96 | animationDuration?: number;
|
97 | |
98 |
|
99 |
|
100 |
|
101 |
|
102 | role?: string;
|
103 | |
104 |
|
105 |
|
106 | ariaLabelledby?: string;
|
107 | |
108 |
|
109 |
|
110 | ariaDescribedby?: string;
|
111 | |
112 |
|
113 |
|
114 | modalId?: string;
|
115 | |
116 |
|
117 |
|
118 | onClose: () => void;
|
119 | |
120 |
|
121 |
|
122 | onEscKeyDown?: (event: KeyboardEvent) => void;
|
123 | |
124 |
|
125 |
|
126 | onOverlayClick?: (
|
127 | event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
128 | ) => void;
|
129 | |
130 |
|
131 |
|
132 | onAnimationEnd?: () => void;
|
133 | children?: React.ReactNode;
|
134 | }
|
135 |
|
136 | export const Modal = ({
|
137 | open,
|
138 | center,
|
139 | blockScroll = true,
|
140 | closeOnEsc = true,
|
141 | closeOnOverlayClick = true,
|
142 | container,
|
143 | showCloseIcon = true,
|
144 | closeIconId,
|
145 | closeIcon,
|
146 | focusTrapped = true,
|
147 | animationDuration = 500,
|
148 | classNames,
|
149 | styles,
|
150 | role = 'dialog',
|
151 | ariaDescribedby,
|
152 | ariaLabelledby,
|
153 | modalId,
|
154 | onClose,
|
155 | onEscKeyDown,
|
156 | onOverlayClick,
|
157 | onAnimationEnd,
|
158 | children,
|
159 | }: ModalProps) => {
|
160 | const refModal = useRef<HTMLDivElement>(null);
|
161 | const refShouldClose = useRef<boolean | null>(null);
|
162 | const refContainer = useRef<HTMLDivElement | null>(null);
|
163 |
|
164 |
|
165 | if (refContainer.current === null && isBrowser) {
|
166 | refContainer.current = document.createElement('div');
|
167 | }
|
168 |
|
169 |
|
170 |
|
171 | const [showPortal, setShowPortal] = useState(false);
|
172 |
|
173 | const handleOpen = () => {
|
174 | modalManager.add(refContainer.current!, blockScroll);
|
175 | if (blockScroll) {
|
176 | blockNoScroll();
|
177 | }
|
178 | if (
|
179 | refContainer.current &&
|
180 | !container &&
|
181 | !document.body.contains(refContainer.current)
|
182 | ) {
|
183 | document.body.appendChild(refContainer.current);
|
184 | }
|
185 | document.addEventListener('keydown', handleKeydown);
|
186 | };
|
187 |
|
188 | const handleClose = () => {
|
189 | modalManager.remove(refContainer.current!);
|
190 | if (blockScroll) {
|
191 | unblockNoScroll();
|
192 | }
|
193 | if (
|
194 | refContainer.current &&
|
195 | !container &&
|
196 | document.body.contains(refContainer.current)
|
197 | ) {
|
198 | document.body.removeChild(refContainer.current);
|
199 | }
|
200 | document.removeEventListener('keydown', handleKeydown);
|
201 | };
|
202 |
|
203 | const handleKeydown = (event: KeyboardEvent) => {
|
204 |
|
205 | if (
|
206 | event.keyCode !== 27 ||
|
207 | !modalManager.isTopModal(refContainer.current!)
|
208 | ) {
|
209 | return;
|
210 | }
|
211 |
|
212 | onEscKeyDown?.(event);
|
213 |
|
214 | if (closeOnEsc) {
|
215 | onClose();
|
216 | }
|
217 | };
|
218 |
|
219 | useEffect(() => {
|
220 | return () => {
|
221 |
|
222 | if (showPortal) {
|
223 | handleClose();
|
224 | }
|
225 | };
|
226 | }, [showPortal]);
|
227 |
|
228 | useEffect(() => {
|
229 |
|
230 |
|
231 | if (open && !showPortal) {
|
232 | setShowPortal(true);
|
233 | handleOpen();
|
234 | }
|
235 | }, [open]);
|
236 |
|
237 | const handleClickOverlay = (
|
238 | event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
239 | ) => {
|
240 | if (refShouldClose.current === null) {
|
241 | refShouldClose.current = true;
|
242 | }
|
243 |
|
244 | if (!refShouldClose.current) {
|
245 | refShouldClose.current = null;
|
246 | return;
|
247 | }
|
248 |
|
249 | onOverlayClick?.(event);
|
250 |
|
251 | if (closeOnOverlayClick) {
|
252 | onClose();
|
253 | }
|
254 |
|
255 | refShouldClose.current = null;
|
256 | };
|
257 |
|
258 | const handleModalEvent = () => {
|
259 | refShouldClose.current = false;
|
260 | };
|
261 |
|
262 | const handleClickCloseIcon = () => {
|
263 | onClose();
|
264 | };
|
265 |
|
266 | const handleAnimationEnd = () => {
|
267 | if (!open) {
|
268 | setShowPortal(false);
|
269 | }
|
270 |
|
271 | onAnimationEnd?.();
|
272 | };
|
273 |
|
274 | const containerModal = container || refContainer.current;
|
275 |
|
276 | return showPortal && containerModal
|
277 | ? ReactDom.createPortal(
|
278 | <div
|
279 | style={{
|
280 | animation: `${
|
281 | open
|
282 | ? classNames?.animationIn ?? classes.animationIn
|
283 | : classNames?.animationOut ?? classes.animationOut
|
284 | } ${animationDuration}ms`,
|
285 | ...styles?.overlay,
|
286 | }}
|
287 | className={cx(classes.overlay, classNames?.overlay)}
|
288 | onClick={handleClickOverlay}
|
289 | onAnimationEnd={handleAnimationEnd}
|
290 | data-testid="overlay"
|
291 | >
|
292 | <div
|
293 | ref={refModal}
|
294 | className={cx(
|
295 | classes.modal,
|
296 | center && classes.modalCenter,
|
297 | classNames?.modal
|
298 | )}
|
299 | style={styles?.modal}
|
300 | onMouseDown={handleModalEvent}
|
301 | onMouseUp={handleModalEvent}
|
302 | onClick={handleModalEvent}
|
303 | id={modalId}
|
304 | role={role}
|
305 | aria-modal="true"
|
306 | aria-labelledby={ariaLabelledby}
|
307 | aria-describedby={ariaDescribedby}
|
308 | data-testid="modal"
|
309 | >
|
310 | {focusTrapped && <FocusTrap container={refModal} />}
|
311 | {children}
|
312 | {showCloseIcon && (
|
313 | <CloseIcon
|
314 | classes={classes}
|
315 | classNames={classNames}
|
316 | styles={styles}
|
317 | closeIcon={closeIcon}
|
318 | onClickCloseIcon={handleClickCloseIcon}
|
319 | id={closeIconId}
|
320 | />
|
321 | )}
|
322 | </div>
|
323 | </div>,
|
324 | containerModal
|
325 | )
|
326 | : null;
|
327 | };
|
328 |
|
329 | export default Modal;
|