diff --git a/frontend/src/Components/Error/ErrorBoundary.tsx b/frontend/src/Components/Error/ErrorBoundary.tsx index 6b27f7a09..3dd9ebff2 100644 --- a/frontend/src/Components/Error/ErrorBoundary.tsx +++ b/frontend/src/Components/Error/ErrorBoundary.tsx @@ -4,6 +4,7 @@ import React, { Component, ErrorInfo } from 'react'; interface ErrorBoundaryProps { children: React.ReactNode; errorComponent: React.ElementType; + onModalClose?: () => void; } interface ErrorBoundaryState { @@ -32,11 +33,17 @@ class ErrorBoundary extends Component { } render() { - const { children, errorComponent: ErrorComponent } = this.props; + const { + children, + errorComponent: ErrorComponent, + onModalClose, + } = this.props; const { error, info } = this.state; if (error) { - return ; + return ( + + ); } return children; diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx index c4e842887..8c1b4a3d2 100644 --- a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx +++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx @@ -18,7 +18,7 @@ import ModalBody from 'Components/Modal/ModalBody'; import Portal from 'Components/Portal'; import Scroller from 'Components/Scroller/Scroller'; import useMeasure from 'Helpers/Hooks/useMeasure'; -import { icons, scrollDirections, sizes } from 'Helpers/Props'; +import { icons } from 'Helpers/Props'; import ArrayElement from 'typings/Helpers/ArrayElement'; import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs'; import { isMobile as isMobileUtil } from 'Utilities/browser'; @@ -562,14 +562,14 @@ function EnhancedSelectInput, V>( {isMobile ? (
diff --git a/frontend/src/Components/Modal/ConfirmModal.tsx b/frontend/src/Components/Modal/ConfirmModal.tsx index 2adf99a3a..9f195d96a 100644 --- a/frontend/src/Components/Modal/ConfirmModal.tsx +++ b/frontend/src/Components/Modal/ConfirmModal.tsx @@ -1,20 +1,16 @@ import React, { useEffect } from 'react'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; -import Modal from 'Components/Modal/Modal'; +import Modal, { ModalProps } from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts'; import { Kind } from 'Helpers/Props/kinds'; -import { Size } from 'Helpers/Props/sizes'; -interface ConfirmModalProps { - className?: string; - isOpen: boolean; +interface ConfirmModalProps extends Omit { kind?: Kind; - size?: Size; title: string; message: React.ReactNode; confirmLabel?: string; diff --git a/frontend/src/Components/Modal/Modal.css b/frontend/src/Components/Modal/Modal.css index f7a229501..2d379bad7 100644 --- a/frontend/src/Components/Modal/Modal.css +++ b/frontend/src/Components/Modal/Modal.css @@ -39,6 +39,12 @@ * Sizes */ +.extraSmall { + composes: modal; + + width: 320px; +} + .small { composes: modal; @@ -63,7 +69,6 @@ width: 1280px; } - .extraExtraLarge { composes: modal; diff --git a/frontend/src/Components/Modal/Modal.css.d.ts b/frontend/src/Components/Modal/Modal.css.d.ts index e582ce0f9..f60af3bc8 100644 --- a/frontend/src/Components/Modal/Modal.css.d.ts +++ b/frontend/src/Components/Modal/Modal.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'extraExtraLarge': string; 'extraLarge': string; + 'extraSmall': string; 'large': string; 'medium': string; 'modal': string; diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js deleted file mode 100644 index 2a0e56fa7..000000000 --- a/frontend/src/Components/Modal/Modal.js +++ /dev/null @@ -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( - -
-
-
- - {children} - -
-
-
-
, - 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; diff --git a/frontend/src/Components/Modal/Modal.tsx b/frontend/src/Components/Modal/Modal.tsx new file mode 100644 index 000000000..cfc157f93 --- /dev/null +++ b/frontend/src/Components/Modal/Modal.tsx @@ -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; + 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(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) => { + isBackdropPressed.current = isTargetBackdrop(event); + }, + [isTargetBackdrop] + ); + + const handleBackdropEndPress = useCallback( + (event: MouseEvent) => { + 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( + +
+
+
+ + {children} + +
+
+
+
, + node! + ); +} + +export default Modal; diff --git a/frontend/src/Components/Modal/ModalBody.js b/frontend/src/Components/Modal/ModalBody.js deleted file mode 100644 index 268f16cbe..000000000 --- a/frontend/src/Components/Modal/ModalBody.js +++ /dev/null @@ -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 ( - - { - hasScroller ? -
- {children} -
: - children - } -
- ); - } - -} - -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; diff --git a/frontend/src/Components/Modal/ModalBody.tsx b/frontend/src/Components/Modal/ModalBody.tsx new file mode 100644 index 000000000..18ef626d6 --- /dev/null +++ b/frontend/src/Components/Modal/ModalBody.tsx @@ -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 ( + + {hasScroller ? ( +
{children}
+ ) : ( + children + )} +
+ ); +} + +export default ModalBody; diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js deleted file mode 100644 index 1d3862a13..000000000 --- a/frontend/src/Components/Modal/ModalContent.js +++ /dev/null @@ -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 ( -
- { - showCloseButton && - - - - } - - {children} -
- ); -} - -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; diff --git a/frontend/src/Components/Modal/ModalContent.tsx b/frontend/src/Components/Modal/ModalContent.tsx new file mode 100644 index 000000000..7d00fdb93 --- /dev/null +++ b/frontend/src/Components/Modal/ModalContent.tsx @@ -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 { + className?: string; + children?: React.ReactNode; + showCloseButton?: boolean; + onModalClose: () => void; +} + +function ModalContent({ + className = styles.modalContent, + children, + showCloseButton = true, + onModalClose, + ...otherProps +}: ModalContentProps) { + return ( +
+ {showCloseButton && ( + + + + )} + + {children} +
+ ); +} + +export default ModalContent; diff --git a/frontend/src/Components/Modal/ModalError.js b/frontend/src/Components/Modal/ModalError.tsx similarity index 63% rename from frontend/src/Components/Modal/ModalError.js rename to frontend/src/Components/Modal/ModalError.tsx index 56b26a1d2..4a48089fb 100644 --- a/frontend/src/Components/Modal/ModalError.js +++ b/frontend/src/Components/Modal/ModalError.tsx @@ -1,6 +1,7 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import ErrorBoundaryError, { + ErrorBoundaryErrorProps, +} from 'Components/Error/ErrorBoundaryError'; import Button from 'Components/Link/Button'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; @@ -9,40 +10,29 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import translate from 'Utilities/String/translate'; import styles from './ModalError.css'; -function ModalError(props) { - const { - onModalClose, - ...otherProps - } = props; +interface ModalErrorProps extends ErrorBoundaryErrorProps { + onModalClose: () => void; +} +function ModalError({ onModalClose, ...otherProps }: ModalErrorProps) { return ( - - {translate('Error')} - + {translate('Error')} - + ); } -ModalError.propTypes = { - onModalClose: PropTypes.func.isRequired -}; - export default ModalError; diff --git a/frontend/src/Components/Modal/ModalFooter.js b/frontend/src/Components/Modal/ModalFooter.js deleted file mode 100644 index 0cf8811d3..000000000 --- a/frontend/src/Components/Modal/ModalFooter.js +++ /dev/null @@ -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 ( -
- {children} -
- ); - } - -} - -ModalFooter.propTypes = { - children: PropTypes.node -}; - -export default ModalFooter; diff --git a/frontend/src/Components/Modal/ModalFooter.tsx b/frontend/src/Components/Modal/ModalFooter.tsx new file mode 100644 index 000000000..801d51bc9 --- /dev/null +++ b/frontend/src/Components/Modal/ModalFooter.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import styles from './ModalFooter.css'; + +interface ModalFooterProps extends React.HTMLAttributes { + children?: React.ReactNode; +} + +function ModalFooter({ children, ...otherProps }: ModalFooterProps) { + return ( +
+ {children} +
+ ); +} + +export default ModalFooter; diff --git a/frontend/src/Components/Modal/ModalHeader.js b/frontend/src/Components/Modal/ModalHeader.js deleted file mode 100644 index 52879b57d..000000000 --- a/frontend/src/Components/Modal/ModalHeader.js +++ /dev/null @@ -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 ( -
- {children} -
- ); - } - -} - -ModalHeader.propTypes = { - children: PropTypes.node -}; - -export default ModalHeader; diff --git a/frontend/src/Components/Modal/ModalHeader.tsx b/frontend/src/Components/Modal/ModalHeader.tsx new file mode 100644 index 000000000..5e7f64ba5 --- /dev/null +++ b/frontend/src/Components/Modal/ModalHeader.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import styles from './ModalHeader.css'; + +interface ModalHeaderProps extends React.HTMLAttributes { + children?: React.ReactNode; +} + +function ModalHeader({ children, ...otherProps }: ModalHeaderProps) { + return ( +
+ {children} +
+ ); +} + +export default ModalHeader; diff --git a/frontend/typings/element-class.ts b/frontend/typings/element-class.ts new file mode 100644 index 000000000..3f14df8ac --- /dev/null +++ b/frontend/typings/element-class.ts @@ -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; +}