1 | import React from 'react';
|
2 | import PropTypes from 'prop-types';
|
3 | import classNames from 'classnames';
|
4 | import Portal from './Portal';
|
5 | import Fade from './Fade';
|
6 | import {
|
7 | getOriginalBodyPadding,
|
8 | conditionallyUpdateScrollbar,
|
9 | setScrollbarWidth,
|
10 | mapToCssModules,
|
11 | omit,
|
12 | focusableElements,
|
13 | TransitionTimeouts,
|
14 | keyCodes,
|
15 | targetPropType,
|
16 | getTarget,
|
17 | } from './utils';
|
18 |
|
19 | function noop() {}
|
20 |
|
21 | const FadePropTypes = PropTypes.shape(Fade.propTypes);
|
22 |
|
23 | const propTypes = {
|
24 |
|
25 | autoFocus: PropTypes.bool,
|
26 |
|
27 | backdrop: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['static'])]),
|
28 |
|
29 | backdropClassName: PropTypes.string,
|
30 | backdropTransition: FadePropTypes,
|
31 |
|
32 | centered: PropTypes.bool,
|
33 |
|
34 | children: PropTypes.node,
|
35 |
|
36 | contentClassName: PropTypes.string,
|
37 | className: PropTypes.string,
|
38 | container: targetPropType,
|
39 | cssModule: PropTypes.object,
|
40 | external: PropTypes.node,
|
41 |
|
42 | fade: PropTypes.bool,
|
43 |
|
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 |
|
54 | isOpen: PropTypes.bool,
|
55 |
|
56 | keyboard: PropTypes.bool,
|
57 |
|
58 | labelledBy: PropTypes.string,
|
59 | modalClassName: PropTypes.string,
|
60 | modalTransition: FadePropTypes,
|
61 |
|
62 | onClosed: PropTypes.func,
|
63 |
|
64 | onEnter: PropTypes.func,
|
65 |
|
66 | onExit: PropTypes.func,
|
67 |
|
68 | onOpened: PropTypes.func,
|
69 |
|
70 | returnFocusAfterClose: PropTypes.bool,
|
71 |
|
72 | role: PropTypes.string,
|
73 |
|
74 | scrollable: PropTypes.bool,
|
75 |
|
76 | size: PropTypes.string,
|
77 |
|
78 | toggle: PropTypes.func,
|
79 | trapFocus: PropTypes.bool,
|
80 |
|
81 | unmountOnClose: PropTypes.bool,
|
82 | wrapClassName: PropTypes.string,
|
83 | zIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
84 | };
|
85 |
|
86 | const propsToOmit = Object.keys(propTypes);
|
87 |
|
88 | const 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,
|
106 | },
|
107 | unmountOnClose: true,
|
108 | returnFocusAfterClose: true,
|
109 | container: 'body',
|
110 | trapFocus: false,
|
111 | };
|
112 |
|
113 | class 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 |
|
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 |
|
165 | return;
|
166 | }
|
167 |
|
168 |
|
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 |
|
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;
|
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 |
|
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 |
|
328 | return;
|
329 | }
|
330 |
|
331 | if (this._dialog && this._dialog.parentNode === ev.target) {
|
332 |
|
333 | return;
|
334 | }
|
335 |
|
336 | if (this.modalIndex < Modal.openCount - 1) {
|
337 |
|
338 | return;
|
339 | }
|
340 |
|
341 | const children = this.getFocusableChildren();
|
342 |
|
343 | for (let i = 0; i < children.length; i += 1) {
|
344 |
|
345 | if (children[i] === ev.target) return;
|
346 | }
|
347 |
|
348 | if (children.length > 0) {
|
349 |
|
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 |
|
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 |
|
573 | Modal.propTypes = propTypes;
|
574 | Modal.defaultProps = defaultProps;
|
575 | Modal.openCount = 0;
|
576 | Modal.originalBodyOverflow = null;
|
577 |
|
578 | export default Modal;
|