src/components/ModalPortal.js
import { Component } from "react";
import PropTypes from "prop-types";
import * as focusManager from "../helpers/focusManager";
import scopeTab from "../helpers/scopeTab";
import * as ariaAppHider from "../helpers/ariaAppHider";
import * as classList from "../helpers/classList";
import SafeHTMLElement, {
SafeHTMLCollection,
SafeNodeList
} from "../helpers/safeHTMLElement";
import portalOpenInstances from "../helpers/portalOpenInstances";
import "../helpers/bodyTrap";
// so that our CSS is statically analyzable
const CLASS_NAMES = {
overlay: "ReactModal__Overlay",
content: "ReactModal__Content"
};
/**
* We need to support the deprecated `KeyboardEvent.keyCode` in addition to
* `KeyboardEvent.code` for apps that still support IE11. Can be removed when
* `react-modal` only supports React >18 (which dropped IE support).
*/
const isTabKey = event => event.code === "Tab" || event.keyCode === 9;
const isEscKey = event => event.code === "Escape" || event.keyCode === 27;
let ariaHiddenInstances = 0;
export default class ModalPortal extends Component {
static defaultProps = {
style: {
overlay: {},
content: {}
},
defaultStyles: {}
};
static propTypes = {
isOpen: PropTypes.bool.isRequired,
defaultStyles: PropTypes.shape({
content: PropTypes.object,
overlay: PropTypes.object
}),
style: PropTypes.shape({
content: PropTypes.object,
overlay: PropTypes.object
}),
className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
overlayClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
parentSelector: PropTypes.func,
bodyOpenClassName: PropTypes.string,
htmlOpenClassName: PropTypes.string,
ariaHideApp: PropTypes.bool,
appElement: PropTypes.oneOfType([
PropTypes.instanceOf(SafeHTMLElement),
PropTypes.instanceOf(SafeHTMLCollection),
PropTypes.instanceOf(SafeNodeList),
PropTypes.arrayOf(PropTypes.instanceOf(SafeHTMLElement))
]),
onAfterOpen: PropTypes.func,
onAfterClose: PropTypes.func,
onRequestClose: PropTypes.func,
closeTimeoutMS: PropTypes.number,
shouldFocusAfterRender: PropTypes.bool,
shouldCloseOnOverlayClick: PropTypes.bool,
shouldReturnFocusAfterClose: PropTypes.bool,
preventScroll: PropTypes.bool,
role: PropTypes.string,
contentLabel: PropTypes.string,
aria: PropTypes.object,
data: PropTypes.object,
children: PropTypes.node,
shouldCloseOnEsc: PropTypes.bool,
overlayRef: PropTypes.func,
contentRef: PropTypes.func,
id: PropTypes.string,
overlayElement: PropTypes.func,
contentElement: PropTypes.func,
testId: PropTypes.string
};
constructor(props) {
super(props);
this.state = {
afterOpen: false,
beforeClose: false
};
this.shouldClose = null;
this.moveFromContentToOverlay = null;
}
componentDidMount() {
if (this.props.isOpen) {
this.open();
}
}
componentDidUpdate(prevProps, prevState) {
if (process.env.NODE_ENV !== "production") {
if (prevProps.bodyOpenClassName !== this.props.bodyOpenClassName) {
// eslint-disable-next-line no-console
console.warn(
'React-Modal: "bodyOpenClassName" prop has been modified. ' +
"This may cause unexpected behavior when multiple modals are open."
);
}
if (prevProps.htmlOpenClassName !== this.props.htmlOpenClassName) {
// eslint-disable-next-line no-console
console.warn(
'React-Modal: "htmlOpenClassName" prop has been modified. ' +
"This may cause unexpected behavior when multiple modals are open."
);
}
}
if (this.props.isOpen && !prevProps.isOpen) {
this.open();
} else if (!this.props.isOpen && prevProps.isOpen) {
this.close();
}
// Focus only needs to be set once when the modal is being opened
if (
this.props.shouldFocusAfterRender &&
this.state.isOpen &&
!prevState.isOpen
) {
this.focusContent();
}
}
componentWillUnmount() {
if (this.state.isOpen) {
this.afterClose();
}
clearTimeout(this.closeTimer);
cancelAnimationFrame(this.openAnimationFrame);
}
setOverlayRef = overlay => {
this.overlay = overlay;
this.props.overlayRef && this.props.overlayRef(overlay);
};
setContentRef = content => {
this.content = content;
this.props.contentRef && this.props.contentRef(content);
};
beforeOpen() {
const {
appElement,
ariaHideApp,
htmlOpenClassName,
bodyOpenClassName,
parentSelector
} = this.props;
const parentDocument =
(parentSelector && parentSelector().ownerDocument) || document;
// Add classes.
bodyOpenClassName && classList.add(parentDocument.body, bodyOpenClassName);
htmlOpenClassName &&
classList.add(
parentDocument.getElementsByTagName("html")[0],
htmlOpenClassName
);
if (ariaHideApp) {
ariaHiddenInstances += 1;
ariaAppHider.hide(appElement);
}
portalOpenInstances.register(this);
}
afterClose = () => {
const {
appElement,
ariaHideApp,
htmlOpenClassName,
bodyOpenClassName,
parentSelector
} = this.props;
const parentDocument =
(parentSelector && parentSelector().ownerDocument) || document;
// Remove classes.
bodyOpenClassName &&
classList.remove(parentDocument.body, bodyOpenClassName);
htmlOpenClassName &&
classList.remove(
parentDocument.getElementsByTagName("html")[0],
htmlOpenClassName
);
// Reset aria-hidden attribute if all modals have been removed
if (ariaHideApp && ariaHiddenInstances > 0) {
ariaHiddenInstances -= 1;
if (ariaHiddenInstances === 0) {
ariaAppHider.show(appElement);
}
}
if (this.props.shouldFocusAfterRender) {
if (this.props.shouldReturnFocusAfterClose) {
focusManager.returnFocus(this.props.preventScroll);
focusManager.teardownScopedFocus();
} else {
focusManager.popWithoutFocus();
}
}
if (this.props.onAfterClose) {
this.props.onAfterClose();
}
portalOpenInstances.deregister(this);
};
open = () => {
this.beforeOpen();
if (this.state.afterOpen && this.state.beforeClose) {
clearTimeout(this.closeTimer);
this.setState({ beforeClose: false });
} else {
if (this.props.shouldFocusAfterRender) {
focusManager.setupScopedFocus(this.node);
focusManager.markForFocusLater();
}
this.setState({ isOpen: true }, () => {
this.openAnimationFrame = requestAnimationFrame(() => {
this.setState({ afterOpen: true });
if (this.props.isOpen && this.props.onAfterOpen) {
this.props.onAfterOpen({
overlayEl: this.overlay,
contentEl: this.content
});
}
});
});
}
};
close = () => {
if (this.props.closeTimeoutMS > 0) {
this.closeWithTimeout();
} else {
this.closeWithoutTimeout();
}
};
// Don't steal focus from inner elements
focusContent = () =>
this.content &&
!this.contentHasFocus() &&
this.content.focus({ preventScroll: true });
closeWithTimeout = () => {
const closesAt = Date.now() + this.props.closeTimeoutMS;
this.setState({ beforeClose: true, closesAt }, () => {
this.closeTimer = setTimeout(
this.closeWithoutTimeout,
this.state.closesAt - Date.now()
);
});
};
closeWithoutTimeout = () => {
this.setState(
{
beforeClose: false,
isOpen: false,
afterOpen: false,
closesAt: null
},
this.afterClose
);
};
handleKeyDown = event => {
if (isTabKey(event)) {
scopeTab(this.content, event);
}
if (this.props.shouldCloseOnEsc && isEscKey(event)) {
event.stopPropagation();
this.requestClose(event);
}
};
handleOverlayOnClick = event => {
if (this.shouldClose === null) {
this.shouldClose = true;
}
if (this.shouldClose && this.props.shouldCloseOnOverlayClick) {
if (this.ownerHandlesClose()) {
this.requestClose(event);
} else {
this.focusContent();
}
}
this.shouldClose = null;
};
handleContentOnMouseUp = () => {
this.shouldClose = false;
};
handleOverlayOnMouseDown = event => {
if (!this.props.shouldCloseOnOverlayClick && event.target == this.overlay) {
event.preventDefault();
}
};
handleContentOnClick = () => {
this.shouldClose = false;
};
handleContentOnMouseDown = () => {
this.shouldClose = false;
};
requestClose = event =>
this.ownerHandlesClose() && this.props.onRequestClose(event);
ownerHandlesClose = () => this.props.onRequestClose;
shouldBeClosed = () => !this.state.isOpen && !this.state.beforeClose;
contentHasFocus = () =>
document.activeElement === this.content ||
this.content.contains(document.activeElement);
buildClassName = (which, additional) => {
const classNames =
typeof additional === "object"
? additional
: {
base: CLASS_NAMES[which],
afterOpen: `${CLASS_NAMES[which]}--after-open`,
beforeClose: `${CLASS_NAMES[which]}--before-close`
};
let className = classNames.base;
if (this.state.afterOpen) {
className = `${className} ${classNames.afterOpen}`;
}
if (this.state.beforeClose) {
className = `${className} ${classNames.beforeClose}`;
}
return typeof additional === "string" && additional
? `${className} ${additional}`
: className;
};
attributesFromObject = (prefix, items) =>
Object.keys(items).reduce((acc, name) => {
acc[`${prefix}-${name}`] = items[name];
return acc;
}, {});
render() {
const {
id,
className,
overlayClassName,
defaultStyles,
children
} = this.props;
const contentStyles = className ? {} : defaultStyles.content;
const overlayStyles = overlayClassName ? {} : defaultStyles.overlay;
if (this.shouldBeClosed()) {
return null;
}
const overlayProps = {
ref: this.setOverlayRef,
className: this.buildClassName("overlay", overlayClassName),
style: { ...overlayStyles, ...this.props.style.overlay },
onClick: this.handleOverlayOnClick,
onMouseDown: this.handleOverlayOnMouseDown
};
const contentProps = {
id,
ref: this.setContentRef,
style: { ...contentStyles, ...this.props.style.content },
className: this.buildClassName("content", className),
tabIndex: "-1",
onKeyDown: this.handleKeyDown,
onMouseDown: this.handleContentOnMouseDown,
onMouseUp: this.handleContentOnMouseUp,
onClick: this.handleContentOnClick,
role: this.props.role,
"aria-label": this.props.contentLabel,
...this.attributesFromObject("aria", { modal: true, ...this.props.aria }),
...this.attributesFromObject("data", this.props.data || {}),
"data-testid": this.props.testId
};
const contentElement = this.props.contentElement(contentProps, children);
return this.props.overlayElement(overlayProps, contentElement);
}
}