Convert Modal components to TypeScript

pull/7605/head
Mark McDowall 2 months ago
parent e8a6ce371d
commit 442b3b506f
No known key found for this signature in database

@ -4,6 +4,7 @@ import React, { Component, ErrorInfo } from 'react';
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: React.ReactNode; children: React.ReactNode;
errorComponent: React.ElementType; errorComponent: React.ElementType;
onModalClose?: () => void;
} }
interface ErrorBoundaryState { interface ErrorBoundaryState {
@ -32,11 +33,17 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
} }
render() { render() {
const { children, errorComponent: ErrorComponent } = this.props; const {
children,
errorComponent: ErrorComponent,
onModalClose,
} = this.props;
const { error, info } = this.state; const { error, info } = this.state;
if (error) { if (error) {
return <ErrorComponent error={error} info={info} />; return (
<ErrorComponent error={error} info={info} onModalClose={onModalClose} />
);
} }
return children; return children;

@ -18,7 +18,7 @@ import ModalBody from 'Components/Modal/ModalBody';
import Portal from 'Components/Portal'; import Portal from 'Components/Portal';
import Scroller from 'Components/Scroller/Scroller'; import Scroller from 'Components/Scroller/Scroller';
import useMeasure from 'Helpers/Hooks/useMeasure'; 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 ArrayElement from 'typings/Helpers/ArrayElement';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs'; import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import { isMobile as isMobileUtil } from 'Utilities/browser'; import { isMobile as isMobileUtil } from 'Utilities/browser';
@ -562,14 +562,14 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
{isMobile ? ( {isMobile ? (
<Modal <Modal
className={styles.optionsModal} className={styles.optionsModal}
size={sizes.EXTRA_SMALL} size="extraSmall"
isOpen={isOpen} isOpen={isOpen}
onModalClose={handleOptionsModalClose} onModalClose={handleOptionsModalClose}
> >
<ModalBody <ModalBody
className={styles.optionsModalBody} className={styles.optionsModalBody}
innerClassName={styles.optionsInnerModalBody} innerClassName={styles.optionsInnerModalBody}
scrollDirection={scrollDirections.NONE} scrollDirection="none"
> >
<Scroller className={styles.optionsModalScroller}> <Scroller className={styles.optionsModalScroller}>
<div className={styles.mobileCloseButtonContainer}> <div className={styles.mobileCloseButtonContainer}>

@ -1,20 +1,16 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton'; 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 ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts'; import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { Kind } from 'Helpers/Props/kinds'; import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
interface ConfirmModalProps { interface ConfirmModalProps extends Omit<ModalProps, 'onModalClose'> {
className?: string;
isOpen: boolean;
kind?: Kind; kind?: Kind;
size?: Size;
title: string; title: string;
message: React.ReactNode; message: React.ReactNode;
confirmLabel?: string; confirmLabel?: string;

@ -39,6 +39,12 @@
* Sizes * Sizes
*/ */
.extraSmall {
composes: modal;
width: 320px;
}
.small { .small {
composes: modal; composes: modal;
@ -63,7 +69,6 @@
width: 1280px; width: 1280px;
} }
.extraExtraLarge { .extraExtraLarge {
composes: modal; composes: modal;

@ -3,6 +3,7 @@
interface CssExports { interface CssExports {
'extraExtraLarge': string; 'extraExtraLarge': string;
'extraLarge': string; 'extraLarge': string;
'extraSmall': string;
'large': string; 'large': string;
'medium': string; 'medium': string;
'modal': string; 'modal': string;

@ -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,6 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; import ErrorBoundaryError, {
ErrorBoundaryErrorProps,
} from 'Components/Error/ErrorBoundaryError';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
@ -9,40 +10,29 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './ModalError.css'; import styles from './ModalError.css';
function ModalError(props) { interface ModalErrorProps extends ErrorBoundaryErrorProps {
const { onModalClose: () => void;
onModalClose, }
...otherProps
} = props;
function ModalError({ onModalClose, ...otherProps }: ModalErrorProps) {
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>{translate('Error')}</ModalHeader>
{translate('Error')}
</ModalHeader>
<ModalBody> <ModalBody>
<ErrorBoundaryError <ErrorBoundaryError
{...otherProps}
messageClassName={styles.message} messageClassName={styles.message}
detailsClassName={styles.details} detailsClassName={styles.details}
{...otherProps}
message={translate('ErrorLoadingItem')} message={translate('ErrorLoadingItem')}
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button <Button onPress={onModalClose}>{translate('Close')}</Button>
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
); );
} }
ModalError.propTypes = {
onModalClose: PropTypes.func.isRequired
};
export default ModalError; export default ModalError;

@ -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…
Cancel
Save