Skip to content

Commit

Permalink
feat(Modal): added support for React Portal (reactstrap#796)
Browse files Browse the repository at this point in the history
  • Loading branch information
fantua authored and TheSharpieOne committed Feb 12, 2018
1 parent 305ba67 commit 49a7f99
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 210 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@
"lodash.tonumber": "^4.0.3",
"prop-types": "^15.5.8",
"react-popper": "^0.7.2",
"react-transition-group": "^2.2.0"
"react-portal": "^4.1.2",
"react-transition-group": "^2.2.1"
},
"peerDependencies": {
"react": "^0.14.9 || ^15.3.0 || ^16.0.0",
Expand All @@ -117,7 +118,7 @@
"css-loader": "^0.25.0",
"ejs": "^2.5.1",
"enzyme": "^3.0.0",
"enzyme-adapter-react-16": "^1.0.0",
"enzyme-adapter-react-16": "^1.1.1",
"eslint": "^4.1.1",
"eslint-config-airbnb": "^15.1.0",
"eslint-plugin-import": "^2.6.0",
Expand Down
268 changes: 128 additions & 140 deletions src/Modal.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import { Portal } from 'react-portal';
import classNames from 'classnames';
import Fade from './Fade';
import {
Expand Down Expand Up @@ -73,40 +73,62 @@ class Modal extends React.Component {
constructor(props) {
super(props);

this.originalBodyPadding = null;
this.isBodyOverflowing = false;
this.togglePortal = this.togglePortal.bind(this);
this._element = null;
this._originalBodyPadding = null;
this.handleBackdropClick = this.handleBackdropClick.bind(this);
this.handleEscape = this.handleEscape.bind(this);
this.destroy = this.destroy.bind(this);
this.onOpened = this.onOpened.bind(this);
this.onClosed = this.onClosed.bind(this);

this.state = {
isOpen: props.isOpen,
};

if (props.isOpen) {
this.init();
}
}

componentDidMount() {
if (this.props.isOpen) {
this.togglePortal();
}
if (this.props.onEnter) {
this.props.onEnter();
}

if (this.state.isOpen && this.props.autoFocus) {
this.setFocus();
}

this._isMounted = true;
}

componentDidUpdate(prevProps) {
if (this.props.isOpen !== prevProps.isOpen) {
// handle portal events/dom updates
this.togglePortal();
} else if (this._element) {
// rerender portal
this.renderIntoSubtree();
componentWillReceiveProps(nextProps) {
if (nextProps.isOpen && !this.props.isOpen) {
this.setState({ isOpen: nextProps.isOpen });
}
}

componentWillUpdate(nextProps, nextState) {
if (nextState.isOpen && !this.state.isOpen) {
this.init();
}
}

componentDidUpdate(prevProps, prevState) {
if (this.props.autoFocus && this.state.isOpen && !prevState.isOpen) {
this.setFocus();
}
}

componentWillUnmount() {
this.destroy();
if (this.props.onExit) {
this.props.onExit();
}

if (this.state.isOpen) {
this.destroy();
}

this._isMounted = false;
}

onOpened(node, isAppearing) {
Expand All @@ -116,19 +138,24 @@ class Modal extends React.Component {

onClosed(node) {
// so all methods get called before it is unmounted
setTimeout(() => this.destroy(), 0);
this.props.onClosed();
(this.props.modalTransition.onExited || noop)(node);
this.destroy();

if (this._isMounted) {
this.setState({ isOpen: false });
}
}

handleEscape(e) {
if (this.props.keyboard && e.keyCode === 27 && this.props.toggle) {
this.props.toggle();
setFocus() {
if (this._dialog && this._dialog.parentNode && typeof this._dialog.parentNode.focus === 'function') {
this._dialog.parentNode.focus();
}
}

handleBackdropClick(e) {
if (this.props.backdrop !== true) return;
e.stopPropagation();
if (!this.props.isOpen || this.props.backdrop !== true) return;

const container = this._dialog;

Expand All @@ -137,80 +164,54 @@ class Modal extends React.Component {
}
}

togglePortal() {
if (this.props.isOpen) {
if (this.props.autoFocus) {
this._focus = true;
}
this.show();
} else {
this.hide();
}
}

destroy() {
if (this._element) {
ReactDOM.unmountComponentAtNode(this._element);
document.body.removeChild(this._element);
this._element = null;
}

if (this.bodyClassAdded) {
const modalOpenClassName = mapToCssModules('modal-open', this.props.cssModule);
// Use regex to prevent matching `modal-open` as part of a different class, e.g. `my-modal-opened`
const modalOpenClassNameRegex = new RegExp(`(^| )${modalOpenClassName}( |$)`);
document.body.className = document.body.className.replace(modalOpenClassNameRegex, ' ').trim();
this.bodyClassAdded = false;
handleEscape(e) {
if (this.props.isOpen && this.props.keyboard && e.keyCode === 27 && this.props.toggle) {
this.props.toggle();
}
setScrollbarWidth(this.originalBodyPadding);
}

hide() {
this.renderIntoSubtree();
}

show() {
if (this._dialog) {
if (this.props.toggle) {
this.props.toggle(true);
}
return;
}
const classes = document.body.className;
init() {
this._element = document.createElement('div');
this._element.setAttribute('tabindex', '-1');
this._element.style.position = 'relative';
this._element.style.zIndex = this.props.zIndex;
this.originalBodyPadding = getOriginalBodyPadding();
this._originalBodyPadding = getOriginalBodyPadding();

conditionallyUpdateScrollbar();

document.body.appendChild(this._element);

if (!this.bodyClassAdded) {
document.body.className = classNames(
classes,
mapToCssModules('modal-open', this.props.cssModule)
);
this.bodyClassAdded = true;
}
document.body.className = classNames(
document.body.className,
mapToCssModules('modal-open', this.props.cssModule)
);
}

this.renderIntoSubtree();
destroy() {
document.body.removeChild(this._element);
this._element = null;

const modalOpenClassName = mapToCssModules('modal-open', this.props.cssModule);
// Use regex to prevent matching `modal-open` as part of a different class, e.g. `my-modal-opened`
const modalOpenClassNameRegex = new RegExp(`(^| )${modalOpenClassName}( |$)`);
document.body.className = document.body.className.replace(modalOpenClassNameRegex, ' ').trim();

setScrollbarWidth(this._originalBodyPadding);
}

renderModalDialog() {
const attributes = omit(this.props, propsToOmit);

return (
<div
{...attributes}
className={mapToCssModules(classNames('modal-dialog', this.props.className, {
[`modal-${this.props.size}`]: this.props.size
}), this.props.cssModule)}
role="document"
ref={(c) => {
this._dialog = c;
}}
{...attributes}
>
<div
className={mapToCssModules(
Expand All @@ -224,80 +225,67 @@ class Modal extends React.Component {
);
}

renderIntoSubtree() {
ReactDOM.unstable_renderSubtreeIntoContainer(
this,
this.renderChildren(),
this._element
);

// check if modal should receive focus
if (this._focus) {
if (this._dialog && this._dialog.parentNode && typeof this._dialog.parentNode.focus === 'function') {
this._dialog.parentNode.focus();
}
this._focus = false;
render() {
if (this.state.isOpen) {
const {
wrapClassName,
modalClassName,
backdropClassName,
cssModule,
isOpen,
backdrop,
role,
labelledBy
} = this.props;

const modalAttributes = {
onClick: this.handleBackdropClick,
onKeyUp: this.handleEscape,
style: { display: 'block' },
'aria-labelledby': labelledBy,
role,
tabIndex: '-1'
};

const hasTransition = this.props.fade;
const modalTransition = {
...Fade.defaultProps,
...this.props.modalTransition,
baseClass: hasTransition ? this.props.modalTransition.baseClass : '',
timeout: hasTransition ? this.props.modalTransition.timeout : 0,
};
const backdropTransition = {
...Fade.defaultProps,
...this.props.backdropTransition,
baseClass: hasTransition ? this.props.backdropTransition.baseClass : '',
timeout: hasTransition ? this.props.backdropTransition.timeout : 0,
};

return (
<Portal node={this._element}>
<div className={mapToCssModules(wrapClassName)}>
<Fade
{...modalAttributes}
{...modalTransition}
in={isOpen}
onEntered={this.onOpened}
onExited={this.onClosed}
cssModule={cssModule}
className={mapToCssModules(classNames('modal', modalClassName), cssModule)}
>
{this.renderModalDialog()}
</Fade>
<Fade
{...backdropTransition}
in={isOpen && !!backdrop}
cssModule={cssModule}
className={mapToCssModules(classNames('modal-backdrop', backdropClassName), cssModule)}
/>
</div>
</Portal>
);
}
}

renderChildren() {
const {
wrapClassName,
modalClassName,
backdropClassName,
cssModule,
isOpen,
backdrop,
role,
labelledBy
} = this.props;

const modalAttributes = {
onClickCapture: this.handleBackdropClick,
onKeyUp: this.handleEscape,
style: { display: 'block' },
'aria-labelledby': labelledBy,
role,
tabIndex: '-1'
};

const hasTransition = this.props.fade;
const modalTransition = {
...Fade.defaultProps,
...this.props.modalTransition,
baseClass: hasTransition ? this.props.modalTransition.baseClass : '',
timeout: hasTransition ? this.props.modalTransition.timeout : 0,
};
const backdropTransition = {
...Fade.defaultProps,
...this.props.backdropTransition,
baseClass: hasTransition ? this.props.backdropTransition.baseClass : '',
timeout: hasTransition ? this.props.backdropTransition.timeout : 0,
};
return (
<div className={mapToCssModules(wrapClassName)}>
<Fade
{...modalAttributes}
{...modalTransition}
in={isOpen}
onEntered={this.onOpened}
onExited={this.onClosed}
cssModule={cssModule}
className={mapToCssModules(classNames('modal', modalClassName), cssModule)}
>
{this.renderModalDialog()}
</Fade>
<Fade
{...backdropTransition}
in={isOpen && !!backdrop}
cssModule={cssModule}
className={mapToCssModules(classNames('modal-backdrop', backdropClassName), cssModule)}
/>
</div>
);
}

render() {
return null;
}
}
Expand Down
Loading

0 comments on commit 49a7f99

Please sign in to comment.