parent
e8a6ce371d
commit
442b3b506f
@ -1,235 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import elementClass from 'element-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import FocusLock from 'react-focus-lock';
|
||||
import ErrorBoundary from 'Components/Error/ErrorBoundary';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { isIOS } from 'Utilities/browser';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import { setScrollLock } from 'Utilities/scrollLock';
|
||||
import ModalError from './ModalError';
|
||||
import styles from './Modal.css';
|
||||
|
||||
const openModals = [];
|
||||
|
||||
function removeFromOpenModals(id) {
|
||||
const index = openModals.indexOf(id);
|
||||
|
||||
if (index >= 0) {
|
||||
openModals.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
class Modal extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._node = document.getElementById('portal-root');
|
||||
this._backgroundRef = null;
|
||||
this._modalId = getUniqueElememtId();
|
||||
this._bodyScrollTop = 0;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.isOpen) {
|
||||
this._openModal();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
isOpen
|
||||
} = this.props;
|
||||
|
||||
if (!prevProps.isOpen && isOpen) {
|
||||
this._openModal();
|
||||
} else if (prevProps.isOpen && !isOpen) {
|
||||
this._closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.isOpen) {
|
||||
this._closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_setBackgroundRef = (ref) => {
|
||||
this._backgroundRef = ref;
|
||||
};
|
||||
|
||||
_openModal() {
|
||||
openModals.push(this._modalId);
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
|
||||
if (openModals.length === 1) {
|
||||
if (isIOS()) {
|
||||
setScrollLock(true);
|
||||
const scrollTop = document.body.scrollTop;
|
||||
this._bodyScrollTop = scrollTop;
|
||||
elementClass(document.body).add(styles.modalOpenIOS);
|
||||
} else {
|
||||
elementClass(document.body).add(styles.modalOpen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() {
|
||||
removeFromOpenModals(this._modalId);
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
|
||||
if (openModals.length === 0) {
|
||||
setScrollLock(false);
|
||||
|
||||
if (isIOS()) {
|
||||
elementClass(document.body).remove(styles.modalOpenIOS);
|
||||
document.body.scrollTop = this._bodyScrollTop;
|
||||
} else {
|
||||
elementClass(document.body).remove(styles.modalOpen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isBackdropTarget(event) {
|
||||
const targetElement = this._findEventTarget(event);
|
||||
|
||||
if (targetElement) {
|
||||
const backgroundElement = ReactDOM.findDOMNode(this._backgroundRef);
|
||||
|
||||
return backgroundElement.isEqualNode(targetElement);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_findEventTarget(event) {
|
||||
const changedTouches = event.changedTouches;
|
||||
|
||||
if (!changedTouches) {
|
||||
return event.target;
|
||||
}
|
||||
|
||||
if (changedTouches.length === 1) {
|
||||
const touch = changedTouches[0];
|
||||
|
||||
return document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onBackdropBeginPress = (event) => {
|
||||
this._isBackdropPressed = this._isBackdropTarget(event);
|
||||
};
|
||||
|
||||
onBackdropEndPress = (event) => {
|
||||
const {
|
||||
closeOnBackgroundClick,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
this._isBackdropPressed &&
|
||||
this._isBackdropTarget(event) &&
|
||||
closeOnBackgroundClick
|
||||
) {
|
||||
onModalClose();
|
||||
}
|
||||
|
||||
this._isBackdropPressed = false;
|
||||
};
|
||||
|
||||
onKeyDown = (event) => {
|
||||
const keyCode = event.keyCode;
|
||||
|
||||
if (keyCode === keyCodes.ESCAPE) {
|
||||
if (openModals.indexOf(this._modalId) === openModals.length - 1) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.props.onModalClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
style,
|
||||
backdropClassName,
|
||||
size,
|
||||
children,
|
||||
isOpen,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<FocusLock disabled={false}>
|
||||
<div
|
||||
className={styles.modalContainer}
|
||||
>
|
||||
<div
|
||||
ref={this._setBackgroundRef}
|
||||
className={backdropClassName}
|
||||
onMouseDown={this.onBackdropBeginPress}
|
||||
onMouseUp={this.onBackdropEndPress}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[size]
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<ErrorBoundary
|
||||
errorComponent={ModalError}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusLock>,
|
||||
this._node
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Modal.propTypes = {
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
backdropClassName: PropTypes.string,
|
||||
size: PropTypes.oneOf(sizes.all),
|
||||
children: PropTypes.node,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
closeOnBackgroundClick: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
Modal.defaultProps = {
|
||||
className: styles.modal,
|
||||
backdropClassName: styles.modalBackdrop,
|
||||
size: sizes.LARGE,
|
||||
closeOnBackgroundClick: true
|
||||
};
|
||||
|
||||
export default Modal;
|
@ -0,0 +1,190 @@
|
||||
import classNames from 'classnames';
|
||||
import elementClass from 'element-class';
|
||||
import React, {
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import FocusLock from 'react-focus-lock';
|
||||
import ErrorBoundary from 'Components/Error/ErrorBoundary';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import { isIOS } from 'Utilities/browser';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import { setScrollLock } from 'Utilities/scrollLock';
|
||||
import ModalError from './ModalError';
|
||||
import styles from './Modal.css';
|
||||
|
||||
const openModals: string[] = [];
|
||||
const node = document.getElementById('portal-root');
|
||||
|
||||
function removeFromOpenModals(id: string) {
|
||||
const index = openModals.indexOf(id);
|
||||
|
||||
if (index >= 0) {
|
||||
openModals.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function findEventTarget(event: TouchEvent | MouseEvent) {
|
||||
if ('changedTouches' in event) {
|
||||
const changedTouches = event.changedTouches;
|
||||
|
||||
if (!changedTouches) {
|
||||
return event.target;
|
||||
}
|
||||
|
||||
if (changedTouches.length === 1) {
|
||||
const touch = changedTouches[0];
|
||||
|
||||
return document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
return event.target;
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
className?: string;
|
||||
style?: object;
|
||||
backdropClassName?: string;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
closeOnBackgroundClick?: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function Modal({
|
||||
className = styles.modal,
|
||||
style,
|
||||
backdropClassName = styles.modalBackdrop,
|
||||
size = 'large',
|
||||
children,
|
||||
isOpen,
|
||||
closeOnBackgroundClick = true,
|
||||
onModalClose,
|
||||
}: ModalProps) {
|
||||
const backgroundRef = useRef<HTMLDivElement>(null);
|
||||
const isBackdropPressed = useRef(false);
|
||||
const bodyScrollTop = useRef(0);
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const modalId = useId();
|
||||
|
||||
const isTargetBackdrop = useCallback((event: TouchEvent | MouseEvent) => {
|
||||
const targetElement = findEventTarget(event);
|
||||
|
||||
if (targetElement) {
|
||||
return backgroundRef.current?.isEqualNode(targetElement as Node) ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleBackdropBeginPress = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
isBackdropPressed.current = isTargetBackdrop(event);
|
||||
},
|
||||
[isTargetBackdrop]
|
||||
);
|
||||
|
||||
const handleBackdropEndPress = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
isBackdropPressed.current &&
|
||||
isTargetBackdrop(event) &&
|
||||
closeOnBackgroundClick
|
||||
) {
|
||||
onModalClose();
|
||||
}
|
||||
|
||||
isBackdropPressed.current = false;
|
||||
},
|
||||
[closeOnBackgroundClick, isTargetBackdrop, onModalClose]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.keyCode === keyCodes.ESCAPE) {
|
||||
if (openModals.indexOf(modalId) === openModals.length - 1) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onModalClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[modalId, onModalClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !wasOpen) {
|
||||
openModals.push(modalId);
|
||||
|
||||
if (openModals.length === 1) {
|
||||
if (isIOS()) {
|
||||
setScrollLock(true);
|
||||
bodyScrollTop.current = document.body.scrollTop;
|
||||
elementClass(document.body).add(styles.modalOpenIOS);
|
||||
} else {
|
||||
elementClass(document.body).add(styles.modalOpen);
|
||||
}
|
||||
}
|
||||
} else if (!isOpen && wasOpen) {
|
||||
removeFromOpenModals(modalId);
|
||||
|
||||
if (openModals.length === 0) {
|
||||
setScrollLock(false);
|
||||
|
||||
if (isIOS()) {
|
||||
elementClass(document.body).remove(styles.modalOpenIOS);
|
||||
document.body.scrollTop = bodyScrollTop.current;
|
||||
} else {
|
||||
elementClass(document.body).remove(styles.modalOpen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, wasOpen, modalId, handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<FocusLock disabled={false}>
|
||||
<div className={styles.modalContainer}>
|
||||
<div
|
||||
ref={backgroundRef}
|
||||
className={backdropClassName}
|
||||
onMouseDown={handleBackdropBeginPress}
|
||||
onMouseUp={handleBackdropEndPress}
|
||||
>
|
||||
<div className={classNames(className, styles[size])} style={style}>
|
||||
<ErrorBoundary
|
||||
errorComponent={ModalError}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusLock>,
|
||||
node!
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
@ -1,59 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import { scrollDirections } from 'Helpers/Props';
|
||||
import styles from './ModalBody.css';
|
||||
|
||||
class ModalBody extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
innerClassName,
|
||||
scrollDirection,
|
||||
children,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
let className = this.props.className;
|
||||
const hasScroller = scrollDirection !== scrollDirections.NONE;
|
||||
|
||||
if (!className) {
|
||||
className = hasScroller ? styles.modalScroller : styles.modalBody;
|
||||
}
|
||||
|
||||
return (
|
||||
<Scroller
|
||||
className={className}
|
||||
scrollDirection={scrollDirection}
|
||||
scrollTop={0}
|
||||
{...otherProps}
|
||||
>
|
||||
{
|
||||
hasScroller ?
|
||||
<div className={innerClassName}>
|
||||
{children}
|
||||
</div> :
|
||||
children
|
||||
}
|
||||
</Scroller>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ModalBody.propTypes = {
|
||||
className: PropTypes.string,
|
||||
innerClassName: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
scrollDirection: PropTypes.oneOf(scrollDirections.all)
|
||||
};
|
||||
|
||||
ModalBody.defaultProps = {
|
||||
innerClassName: styles.innerModalBody,
|
||||
scrollDirection: scrollDirections.VERTICAL
|
||||
};
|
||||
|
||||
export default ModalBody;
|
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
|
||||
import styles from './ModalBody.css';
|
||||
|
||||
interface ModalBodyProps {
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
children?: React.ReactNode;
|
||||
scrollDirection?: ScrollDirection;
|
||||
}
|
||||
|
||||
function ModalBody({
|
||||
innerClassName = styles.innerModalBody,
|
||||
scrollDirection = 'vertical',
|
||||
children,
|
||||
...otherProps
|
||||
}: ModalBodyProps) {
|
||||
let className = otherProps.className;
|
||||
const hasScroller = scrollDirection !== 'none';
|
||||
|
||||
if (!className) {
|
||||
className = hasScroller ? styles.modalScroller : styles.modalBody;
|
||||
}
|
||||
|
||||
return (
|
||||
<Scroller
|
||||
{...otherProps}
|
||||
className={className}
|
||||
scrollDirection={scrollDirection}
|
||||
scrollTop={0}
|
||||
>
|
||||
{hasScroller ? (
|
||||
<div className={innerClassName}>{children}</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Scroller>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalBody;
|
@ -1,54 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ModalContent.css';
|
||||
|
||||
function ModalContent(props) {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
showCloseButton,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
{...otherProps}
|
||||
>
|
||||
{
|
||||
showCloseButton &&
|
||||
<Link
|
||||
className={styles.closeButton}
|
||||
onPress={onModalClose}
|
||||
>
|
||||
<Icon
|
||||
name={icons.CLOSE}
|
||||
size={18}
|
||||
title={translate('Close')}
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ModalContent.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
showCloseButton: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ModalContent.defaultProps = {
|
||||
className: styles.modalContent,
|
||||
showCloseButton: true
|
||||
};
|
||||
|
||||
export default ModalContent;
|
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ModalContent.css';
|
||||
|
||||
interface ModalContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
showCloseButton?: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function ModalContent({
|
||||
className = styles.modalContent,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: ModalContentProps) {
|
||||
return (
|
||||
<div className={className} {...otherProps}>
|
||||
{showCloseButton && (
|
||||
<Link className={styles.closeButton} onPress={onModalClose}>
|
||||
<Icon name={icons.CLOSE} size={18} title={translate('Close')} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalContent;
|
@ -1,32 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './ModalFooter.css';
|
||||
|
||||
class ModalFooter extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.modalFooter}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ModalFooter.propTypes = {
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default ModalFooter;
|
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import styles from './ModalFooter.css';
|
||||
|
||||
interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ModalFooter({ children, ...otherProps }: ModalFooterProps) {
|
||||
return (
|
||||
<div className={styles.modalFooter} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalFooter;
|
@ -1,32 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './ModalHeader.css';
|
||||
|
||||
class ModalHeader extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.modalHeader}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ModalHeader.propTypes = {
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
export default ModalHeader;
|
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import styles from './ModalHeader.css';
|
||||
|
||||
interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ModalHeader({ children, ...otherProps }: ModalHeaderProps) {
|
||||
return (
|
||||
<div className={styles.modalHeader} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalHeader;
|
@ -0,0 +1,10 @@
|
||||
declare module 'element-class' {
|
||||
function elementClass(element: HTMLElement): ElementClass;
|
||||
|
||||
export = elementClass;
|
||||
}
|
||||
|
||||
interface ElementClass {
|
||||
add: (className: string) => void;
|
||||
remove: (className: string) => void;
|
||||
}
|
Loading…
Reference in new issue