UNPKG

15.8 kBJavaScriptView Raw
1import React from 'react';
2import PropTypes from 'prop-types';
3import classNames from 'classnames';
4import Portal from './Portal';
5import Fade from './Fade';
6import {
7 getOriginalBodyPadding,
8 conditionallyUpdateScrollbar,
9 setScrollbarWidth,
10 mapToCssModules,
11 omit,
12 focusableElements,
13 TransitionTimeouts,
14 keyCodes,
15 targetPropType,
16 getTarget,
17} from './utils';
18
19function noop() {}
20
21const FadePropTypes = PropTypes.shape(Fade.propTypes);
22
23const propTypes = {
24 /** */
25 autoFocus: PropTypes.bool,
26 /** Add backdrop to modal */
27 backdrop: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['static'])]),
28 /** add custom classname to backdrop */
29 backdropClassName: PropTypes.string,
30 backdropTransition: FadePropTypes,
31 /** Vertically center the modal */
32 centered: PropTypes.bool,
33 /** Add children for the modal to wrap */
34 children: PropTypes.node,
35 /** Add custom className for modal content */
36 contentClassName: PropTypes.string,
37 className: PropTypes.string,
38 container: targetPropType,
39 cssModule: PropTypes.object,
40 external: PropTypes.node,
41 /** Enable/Disable animation */
42 fade: PropTypes.bool,
43 /** Make the modal fullscreen */
44 fullscreen: PropTypes.oneOfType([
45 PropTypes.bool,
46 PropTypes.oneOf(['sm', 'md', 'lg', 'xl']),
47 ]),
48 innerRef: PropTypes.oneOfType([
49 PropTypes.object,
50 PropTypes.string,
51 PropTypes.func,
52 ]),
53 /** The status of the modal, either open or close */
54 isOpen: PropTypes.bool,
55 /** Allow modal to be closed with escape key. */
56 keyboard: PropTypes.bool,
57 /** Identifies the element (or elements) that labels the current element. */
58 labelledBy: PropTypes.string,
59 modalClassName: PropTypes.string,
60 modalTransition: FadePropTypes,
61 /** Function to be triggered on close */
62 onClosed: PropTypes.func,
63 /** Function to be triggered on enter */
64 onEnter: PropTypes.func,
65 /** Function to be triggered on exit */
66 onExit: PropTypes.func,
67 /** Function to be triggered on open */
68 onOpened: PropTypes.func,
69 /** Returns focus to the element that triggered opening of the modal */
70 returnFocusAfterClose: PropTypes.bool,
71 /** Accessibility role */
72 role: PropTypes.string,
73 /** Make the modal scrollable */
74 scrollable: PropTypes.bool,
75 /** Two optional sizes `lg` and `sm` */
76 size: PropTypes.string,
77 /** Function to toggle modal visibility */
78 toggle: PropTypes.func,
79 trapFocus: PropTypes.bool,
80 /** Unmounts the modal when modal is closed */
81 unmountOnClose: PropTypes.bool,
82 wrapClassName: PropTypes.string,
83 zIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
84};
85
86const propsToOmit = Object.keys(propTypes);
87
88const defaultProps = {
89 isOpen: false,
90 autoFocus: true,
91 centered: false,
92 scrollable: false,
93 role: 'dialog',
94 backdrop: true,
95 keyboard: true,
96 zIndex: 1050,
97 fade: true,
98 onOpened: noop,
99 onClosed: noop,
100 modalTransition: {
101 timeout: TransitionTimeouts.Modal,
102 },
103 backdropTransition: {
104 mountOnEnter: true,
105 timeout: TransitionTimeouts.Fade, // uses standard fade transition
106 },
107 unmountOnClose: true,
108 returnFocusAfterClose: true,
109 container: 'body',
110 trapFocus: false,
111};
112
113class Modal extends React.Component {
114 constructor(props) {
115 super(props);
116
117 this._element = null;
118 this._originalBodyPadding = null;
119 this.getFocusableChildren = this.getFocusableChildren.bind(this);
120 this.handleBackdropClick = this.handleBackdropClick.bind(this);
121 this.handleBackdropMouseDown = this.handleBackdropMouseDown.bind(this);
122 this.handleEscape = this.handleEscape.bind(this);
123 this.handleStaticBackdropAnimation =
124 this.handleStaticBackdropAnimation.bind(this);
125 this.handleTab = this.handleTab.bind(this);
126 this.onOpened = this.onOpened.bind(this);
127 this.onClosed = this.onClosed.bind(this);
128 this.manageFocusAfterClose = this.manageFocusAfterClose.bind(this);
129 this.clearBackdropAnimationTimeout =
130 this.clearBackdropAnimationTimeout.bind(this);
131 this.trapFocus = this.trapFocus.bind(this);
132
133 this.state = {
134 isOpen: false,
135 showStaticBackdropAnimation: false,
136 };
137 }
138
139 componentDidMount() {
140 const { isOpen, autoFocus, onEnter } = this.props;
141
142 if (isOpen) {
143 this.init();
144 this.setState({ isOpen: true });
145 if (autoFocus) {
146 this.setFocus();
147 }
148 }
149
150 if (onEnter) {
151 onEnter();
152 }
153
154 // traps focus inside the Modal, even if the browser address bar is focused
155 document.addEventListener('focus', this.trapFocus, true);
156
157 this._isMounted = true;
158 }
159
160 componentDidUpdate(prevProps, prevState) {
161 if (this.props.isOpen && !prevProps.isOpen) {
162 this.init();
163 this.setState({ isOpen: true });
164 // let render() renders Modal Dialog first
165 return;
166 }
167
168 // now Modal Dialog is rendered and we can refer this._element and this._dialog
169 if (this.props.autoFocus && this.state.isOpen && !prevState.isOpen) {
170 this.setFocus();
171 }
172
173 if (this._element && prevProps.zIndex !== this.props.zIndex) {
174 this._element.style.zIndex = this.props.zIndex;
175 }
176 }
177
178 componentWillUnmount() {
179 this.clearBackdropAnimationTimeout();
180
181 if (this.props.onExit) {
182 this.props.onExit();
183 }
184
185 if (this._element) {
186 this.destroy();
187 if (this.props.isOpen || this.state.isOpen) {
188 this.close();
189 }
190 }
191
192 document.removeEventListener('focus', this.trapFocus, true);
193 this._isMounted = false;
194 }
195
196 // not mouseUp because scrollbar fires it, shouldn't close when user scrolls
197 handleBackdropClick(e) {
198 if (e.target === this._mouseDownElement) {
199 e.stopPropagation();
200
201 const backdrop = this._dialog ? this._dialog.parentNode : null;
202
203 if (
204 backdrop &&
205 e.target === backdrop &&
206 this.props.backdrop === 'static'
207 ) {
208 this.handleStaticBackdropAnimation();
209 }
210
211 if (!this.props.isOpen || this.props.backdrop !== true) return;
212
213 if (backdrop && e.target === backdrop && this.props.toggle) {
214 this.props.toggle(e);
215 }
216 }
217 }
218
219 handleTab(e) {
220 if (e.which !== 9) return;
221 if (this.modalIndex < Modal.openCount - 1) return; // last opened modal
222
223 const focusableChildren = this.getFocusableChildren();
224 const totalFocusable = focusableChildren.length;
225 if (totalFocusable === 0) return;
226 const currentFocus = this.getFocusedChild();
227
228 let focusedIndex = 0;
229
230 for (let i = 0; i < totalFocusable; i += 1) {
231 if (focusableChildren[i] === currentFocus) {
232 focusedIndex = i;
233 break;
234 }
235 }
236
237 if (e.shiftKey && focusedIndex === 0) {
238 e.preventDefault();
239 focusableChildren[totalFocusable - 1].focus();
240 } else if (!e.shiftKey && focusedIndex === totalFocusable - 1) {
241 e.preventDefault();
242 focusableChildren[0].focus();
243 }
244 }
245
246 handleBackdropMouseDown(e) {
247 this._mouseDownElement = e.target;
248 }
249
250 handleEscape(e) {
251 if (this.props.isOpen && e.keyCode === keyCodes.esc && this.props.toggle) {
252 if (this.props.keyboard) {
253 e.preventDefault();
254 e.stopPropagation();
255
256 this.props.toggle(e);
257 } else if (this.props.backdrop === 'static') {
258 e.preventDefault();
259 e.stopPropagation();
260
261 this.handleStaticBackdropAnimation();
262 }
263 }
264 }
265
266 handleStaticBackdropAnimation() {
267 this.clearBackdropAnimationTimeout();
268 this.setState({ showStaticBackdropAnimation: true });
269 this._backdropAnimationTimeout = setTimeout(() => {
270 this.setState({ showStaticBackdropAnimation: false });
271 }, 100);
272 }
273
274 onOpened(node, isAppearing) {
275 this.props.onOpened();
276 (this.props.modalTransition.onEntered || noop)(node, isAppearing);
277 }
278
279 onClosed(node) {
280 const { unmountOnClose } = this.props;
281 // so all methods get called before it is unmounted
282 this.props.onClosed();
283 (this.props.modalTransition.onExited || noop)(node);
284
285 if (unmountOnClose) {
286 this.destroy();
287 }
288 this.close();
289
290 if (this._isMounted) {
291 this.setState({ isOpen: false });
292 }
293 }
294
295 setFocus() {
296 if (
297 this._dialog &&
298 this._dialog.parentNode &&
299 typeof this._dialog.parentNode.focus === 'function'
300 ) {
301 this._dialog.parentNode.focus();
302 }
303 }
304
305 getFocusableChildren() {
306 return this._element.querySelectorAll(focusableElements.join(', '));
307 }
308
309 getFocusedChild() {
310 let currentFocus;
311 const focusableChildren = this.getFocusableChildren();
312
313 try {
314 currentFocus = document.activeElement;
315 } catch (err) {
316 currentFocus = focusableChildren[0];
317 }
318 return currentFocus;
319 }
320
321 trapFocus(ev) {
322 if (!this.props.trapFocus) {
323 return;
324 }
325
326 if (!this._element) {
327 // element is not attached
328 return;
329 }
330
331 if (this._dialog && this._dialog.parentNode === ev.target) {
332 // initial focus when the Modal is opened
333 return;
334 }
335
336 if (this.modalIndex < Modal.openCount - 1) {
337 // last opened modal
338 return;
339 }
340
341 const children = this.getFocusableChildren();
342
343 for (let i = 0; i < children.length; i += 1) {
344 // focus is already inside the Modal
345 if (children[i] === ev.target) return;
346 }
347
348 if (children.length > 0) {
349 // otherwise focus the first focusable element in the Modal
350 ev.preventDefault();
351 ev.stopPropagation();
352 children[0].focus();
353 }
354 }
355
356 init() {
357 try {
358 this._triggeringElement = document.activeElement;
359 } catch (err) {
360 this._triggeringElement = null;
361 }
362
363 if (!this._element) {
364 this._element = document.createElement('div');
365 this._element.setAttribute('tabindex', '-1');
366 this._element.style.position = 'relative';
367 this._element.style.zIndex = this.props.zIndex;
368 this._mountContainer = getTarget(this.props.container);
369 this._mountContainer.appendChild(this._element);
370 }
371
372 this._originalBodyPadding = getOriginalBodyPadding();
373 if (Modal.openCount < 1) {
374 Modal.originalBodyOverflow = window.getComputedStyle(
375 document.body,
376 ).overflow;
377 }
378 conditionallyUpdateScrollbar();
379
380 if (Modal.openCount === 0) {
381 document.body.className = classNames(
382 document.body.className,
383 mapToCssModules('modal-open', this.props.cssModule),
384 );
385 document.body.style.overflow = 'hidden';
386 }
387
388 this.modalIndex = Modal.openCount;
389 Modal.openCount += 1;
390 }
391
392 destroy() {
393 if (this._element) {
394 this._mountContainer.removeChild(this._element);
395 this._element = null;
396 }
397
398 this.manageFocusAfterClose();
399 }
400
401 manageFocusAfterClose() {
402 if (this._triggeringElement) {
403 const { returnFocusAfterClose } = this.props;
404 if (this._triggeringElement.focus && returnFocusAfterClose)
405 this._triggeringElement.focus();
406 this._triggeringElement = null;
407 }
408 }
409
410 close() {
411 if (Modal.openCount <= 1) {
412 const modalOpenClassName = mapToCssModules(
413 'modal-open',
414 this.props.cssModule,
415 );
416 // Use regex to prevent matching `modal-open` as part of a different class, e.g. `my-modal-opened`
417 const modalOpenClassNameRegex = new RegExp(
418 `(^| )${modalOpenClassName}( |$)`,
419 );
420 document.body.className = document.body.className
421 .replace(modalOpenClassNameRegex, ' ')
422 .trim();
423 document.body.style.overflow = Modal.originalBodyOverflow;
424 }
425 this.manageFocusAfterClose();
426 Modal.openCount = Math.max(0, Modal.openCount - 1);
427
428 setScrollbarWidth(this._originalBodyPadding);
429 }
430
431 clearBackdropAnimationTimeout() {
432 if (this._backdropAnimationTimeout) {
433 clearTimeout(this._backdropAnimationTimeout);
434 this._backdropAnimationTimeout = undefined;
435 }
436 }
437
438 renderModalDialog() {
439 const attributes = omit(this.props, propsToOmit);
440 const dialogBaseClass = 'modal-dialog';
441
442 return (
443 <div
444 {...attributes}
445 className={mapToCssModules(
446 classNames(dialogBaseClass, this.props.className, {
447 [`modal-${this.props.size}`]: this.props.size,
448 [`${dialogBaseClass}-centered`]: this.props.centered,
449 [`${dialogBaseClass}-scrollable`]: this.props.scrollable,
450 'modal-fullscreen': this.props.fullscreen === true,
451 [`modal-fullscreen-${this.props.fullscreen}-down`]:
452 typeof this.props.fullscreen === 'string',
453 }),
454 this.props.cssModule,
455 )}
456 role="document"
457 ref={(c) => {
458 this._dialog = c;
459 }}
460 >
461 <div
462 className={mapToCssModules(
463 classNames('modal-content', this.props.contentClassName),
464 this.props.cssModule,
465 )}
466 >
467 {this.props.children}
468 </div>
469 </div>
470 );
471 }
472
473 render() {
474 const { unmountOnClose } = this.props;
475
476 if (!!this._element && (this.state.isOpen || !unmountOnClose)) {
477 const isModalHidden =
478 !!this._element && !this.state.isOpen && !unmountOnClose;
479 this._element.style.display = isModalHidden ? 'none' : 'block';
480
481 const {
482 wrapClassName,
483 modalClassName,
484 backdropClassName,
485 cssModule,
486 isOpen,
487 backdrop,
488 role,
489 labelledBy,
490 external,
491 innerRef,
492 } = this.props;
493
494 const modalAttributes = {
495 onClick: this.handleBackdropClick,
496 onMouseDown: this.handleBackdropMouseDown,
497 onKeyUp: this.handleEscape,
498 onKeyDown: this.handleTab,
499 style: { display: 'block' },
500 'aria-labelledby': labelledBy,
501 'aria-modal': true,
502 role,
503 tabIndex: '-1',
504 };
505
506 const hasTransition = this.props.fade;
507 const modalTransition = {
508 ...Fade.defaultProps,
509 ...this.props.modalTransition,
510 baseClass: hasTransition ? this.props.modalTransition.baseClass : '',
511 timeout: hasTransition ? this.props.modalTransition.timeout : 0,
512 };
513 const backdropTransition = {
514 ...Fade.defaultProps,
515 ...this.props.backdropTransition,
516 baseClass: hasTransition ? this.props.backdropTransition.baseClass : '',
517 timeout: hasTransition ? this.props.backdropTransition.timeout : 0,
518 };
519
520 const Backdrop =
521 backdrop &&
522 (hasTransition ? (
523 <Fade
524 {...backdropTransition}
525 in={isOpen && !!backdrop}
526 cssModule={cssModule}
527 className={mapToCssModules(
528 classNames('modal-backdrop', backdropClassName),
529 cssModule,
530 )}
531 />
532 ) : (
533 <div
534 className={mapToCssModules(
535 classNames('modal-backdrop', 'show', backdropClassName),
536 cssModule,
537 )}
538 />
539 ));
540
541 return (
542 <Portal node={this._element}>
543 <div className={mapToCssModules(wrapClassName)}>
544 <Fade
545 {...modalAttributes}
546 {...modalTransition}
547 in={isOpen}
548 onEntered={this.onOpened}
549 onExited={this.onClosed}
550 cssModule={cssModule}
551 className={mapToCssModules(
552 classNames(
553 'modal',
554 modalClassName,
555 this.state.showStaticBackdropAnimation && 'modal-static',
556 ),
557 cssModule,
558 )}
559 innerRef={innerRef}
560 >
561 {external}
562 {this.renderModalDialog()}
563 </Fade>
564 {Backdrop}
565 </div>
566 </Portal>
567 );
568 }
569 return null;
570 }
571}
572
573Modal.propTypes = propTypes;
574Modal.defaultProps = defaultProps;
575Modal.openCount = 0;
576Modal.originalBodyOverflow = null;
577
578export default Modal;