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 | const [showPortal, setShowPortal] = useState(open);
|
170 |
|
171 | const handleOpen = () => {
|
172 | modalManager.add(refContainer.current!, blockScroll);
|
173 | if (blockScroll) {
|
174 | blockNoScroll();
|
175 | }
|
176 | if (
|
177 | refContainer.current &&
|
178 | !container &&
|
179 | !document.body.contains(refContainer.current)
|
180 | ) {
|
181 | document.body.appendChild(refContainer.current);
|
182 | }
|
183 | document.addEventListener('keydown', handleKeydown);
|
184 | };
|
185 |
|
186 | const handleClose = () => {
|
187 | modalManager.remove(refContainer.current!);
|
188 | if (blockScroll) {
|
189 | unblockNoScroll();
|
190 | }
|
191 | if (
|
192 | refContainer.current &&
|
193 | !container &&
|
194 | document.body.contains(refContainer.current)
|
195 | ) {
|
196 | document.body.removeChild(refContainer.current);
|
197 | }
|
198 | document.removeEventListener('keydown', handleKeydown);
|
199 | };
|
200 |
|
201 | const handleKeydown = (event: KeyboardEvent) => {
|
202 |
|
203 | if (
|
204 | event.keyCode !== 27 ||
|
205 | !modalManager.isTopModal(refContainer.current!)
|
206 | ) {
|
207 | return;
|
208 | }
|
209 |
|
210 | if (onEscKeyDown) {
|
211 | onEscKeyDown(event);
|
212 | }
|
213 |
|
214 | if (closeOnEsc) {
|
215 | onClose();
|
216 | }
|
217 | };
|
218 |
|
219 | useEffect(() => {
|
220 |
|
221 | if (open) {
|
222 | handleOpen();
|
223 | }
|
224 | return () => {
|
225 |
|
226 | if (showPortal) {
|
227 | handleClose();
|
228 | }
|
229 | };
|
230 | }, []);
|
231 |
|
232 | useEffect(() => {
|
233 |
|
234 | if (open && !showPortal) {
|
235 | setShowPortal(true);
|
236 | handleOpen();
|
237 | }
|
238 | }, [open]);
|
239 |
|
240 | const handleClickOverlay = (
|
241 | event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
242 | ) => {
|
243 | if (refShouldClose.current === null) {
|
244 | refShouldClose.current = true;
|
245 | }
|
246 |
|
247 | if (!refShouldClose.current) {
|
248 | refShouldClose.current = null;
|
249 | return;
|
250 | }
|
251 |
|
252 | if (onOverlayClick) {
|
253 | onOverlayClick(event);
|
254 | }
|
255 |
|
256 | if (closeOnOverlayClick) {
|
257 | onClose();
|
258 | }
|
259 |
|
260 | refShouldClose.current = null;
|
261 | };
|
262 |
|
263 | const handleModalEvent = () => {
|
264 | refShouldClose.current = false;
|
265 | };
|
266 |
|
267 | const handleClickCloseIcon = () => {
|
268 | onClose();
|
269 | };
|
270 |
|
271 | const handleAnimationEnd = () => {
|
272 | if (!open) {
|
273 | setShowPortal(false);
|
274 | handleClose();
|
275 | }
|
276 |
|
277 | if (blockScroll) {
|
278 | unblockNoScroll();
|
279 | }
|
280 |
|
281 | if (onAnimationEnd) {
|
282 | onAnimationEnd();
|
283 | }
|
284 | };
|
285 |
|
286 | return showPortal
|
287 | ? ReactDom.createPortal(
|
288 | <div
|
289 | style={{
|
290 | animation: `${
|
291 | open
|
292 | ? classNames?.animationIn ?? classes.animationIn
|
293 | : classNames?.animationOut ?? classes.animationOut
|
294 | } ${animationDuration}ms`,
|
295 | ...styles?.overlay,
|
296 | }}
|
297 | className={cx(classes.overlay, classNames?.overlay)}
|
298 | onClick={handleClickOverlay}
|
299 | onAnimationEnd={handleAnimationEnd}
|
300 | data-testid="overlay"
|
301 | >
|
302 | <div
|
303 | ref={refModal}
|
304 | className={cx(
|
305 | classes.modal,
|
306 | center && classes.modalCenter,
|
307 | classNames?.modal
|
308 | )}
|
309 | style={styles?.modal}
|
310 | onMouseDown={handleModalEvent}
|
311 | onMouseUp={handleModalEvent}
|
312 | onClick={handleModalEvent}
|
313 | id={modalId}
|
314 | role={role}
|
315 | aria-modal="true"
|
316 | aria-labelledby={ariaLabelledby}
|
317 | aria-describedby={ariaDescribedby}
|
318 | data-testid="modal"
|
319 | >
|
320 | {focusTrapped && <FocusTrap container={refModal} />}
|
321 | {children}
|
322 | {showCloseIcon && (
|
323 | <CloseIcon
|
324 | classes={classes}
|
325 | classNames={classNames}
|
326 | styles={styles}
|
327 | closeIcon={closeIcon}
|
328 | onClickCloseIcon={handleClickCloseIcon}
|
329 | id={closeIconId}
|
330 | />
|
331 | )}
|
332 | </div>
|
333 | </div>,
|
334 | container || refContainer.current!
|
335 | )
|
336 | : null;
|
337 | };
|
338 |
|
339 | export default Modal;
|