UNPKG

8.15 kBTypeScriptView Raw
1import React, { useEffect, useState, useRef } from 'react';
2import ReactDom from 'react-dom';
3import cx from 'classnames';
4import CloseIcon from './CloseIcon';
5import { FocusTrap } from './FocusTrap';
6import modalManager from './modalManager';
7import { isBrowser, blockNoScroll, unblockNoScroll } from './utils';
8
9const 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
18export interface ModalProps {
19 /**
20 * Control if the modal is open or not.
21 */
22 open: boolean;
23 /**
24 * Should the dialog be centered.
25 *
26 * Default to false.
27 */
28 center?: boolean;
29 /**
30 * Is the modal closable when user press esc key.
31 *
32 * Default to true.
33 */
34 closeOnEsc?: boolean;
35 /**
36 * Is the modal closable when user click on overlay.
37 *
38 * Default to true.
39 */
40 closeOnOverlayClick?: boolean;
41 /**
42 * Whether to block scrolling when dialog is open.
43 *
44 * Default to true.
45 */
46 blockScroll?: boolean;
47 /**
48 * Show the close icon.
49 */
50 showCloseIcon?: boolean;
51 /**
52 * id attribute for the close icon button.
53 */
54 closeIconId?: string;
55 /**
56 * Custom icon to render (svg, img, etc...).
57 */
58 closeIcon?: React.ReactNode;
59 /**
60 * When the modal is open, trap focus within it.
61 *
62 * Default to true.
63 */
64 focusTrapped?: boolean;
65 /**
66 * You can specify a container prop which should be of type `Element`.
67 * The portal will be rendered inside that element.
68 * The default behavior will create a div node and render it at the at the end of document.body.
69 */
70 container?: Element;
71 /**
72 * An object containing classNames to style the modal.
73 */
74 classNames?: {
75 overlay?: string;
76 modal?: string;
77 closeButton?: string;
78 closeIcon?: string;
79 animationIn?: string;
80 animationOut?: string;
81 };
82 /**
83 * An object containing the styles objects to style the modal.
84 */
85 styles?: {
86 overlay?: React.CSSProperties;
87 modal?: React.CSSProperties;
88 closeButton?: React.CSSProperties;
89 closeIcon?: React.CSSProperties;
90 };
91 /**
92 * Animation duration in milliseconds.
93 *
94 * Default to 500.
95 */
96 animationDuration?: number;
97 /**
98 * ARIA role for modal
99 *
100 * Default to 'dialog'.
101 */
102 role?: string;
103 /**
104 * ARIA label for modal
105 */
106 ariaLabelledby?: string;
107 /**
108 * ARIA description for modal
109 */
110 ariaDescribedby?: string;
111 /**
112 * id attribute for modal
113 */
114 modalId?: string;
115 /**
116 * Callback fired when the Modal is requested to be closed by a click on the overlay or when user press esc key.
117 */
118 onClose: () => void;
119 /**
120 * Callback fired when the escape key is pressed.
121 */
122 onEscKeyDown?: (event: KeyboardEvent) => void;
123 /**
124 * Callback fired when the overlay is clicked.
125 */
126 onOverlayClick?: (
127 event: React.MouseEvent<HTMLDivElement, MouseEvent>
128 ) => void;
129 /**
130 * Callback fired when the Modal has exited and the animation is finished.
131 */
132 onAnimationEnd?: () => void;
133 children?: React.ReactNode;
134}
135
136export 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 // Lazily create the ref instance
164 // https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily
165 if (refContainer.current === null && isBrowser) {
166 refContainer.current = document.createElement('div');
167 }
168
169 // The value should be false for srr, that way when the component is hydrated client side,
170 // it will match the server rendered content
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 // Only the last modal need to be escaped when pressing the esc key
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 // When the component is unmounted directly we want to unblock the scroll
222 if (showPortal) {
223 handleClose();
224 }
225 };
226 }, [showPortal]);
227
228 useEffect(() => {
229 // If the open prop is changing, we need to open the modal
230 // This is also called on the first render if the open prop is true when the modal is created
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
329export default Modal;