nexxtway/react-rainbow

View on GitHub
src/components/Modal/index.js

Summary

Maintainability
B
5 hrs
Test Coverage
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { createPortal } from 'react-dom';
import RenderIf from '../RenderIf';
import { uniqueId } from '../../libs/utils';
import { ESCAPE_KEY, TAB_KEY } from '../../libs/constants';
import Header from './header';
import CloseIcon from './closeIcon';
import manageTab from '../../libs/manageTab';
import {
    disableBodyScroll,
    enableBodyScroll,
    clearAllBodyScrollLocks,
} from '../../libs/scrollController';
import CounterManager from '../../libs/counterManager';
import StyledBackDrop from './styled/backDrop';
import StyledModalContainer from './styled/modalContainer';
import StyledCloseButton from './styled/closeButton';
import StyledContent from './styled/content';
import StyledFooter from './styled/footer';

/**
 * Modals are used to display content in a layer above the app.
 * This is used in cases such as the creation or editing of a record,
 * as well as various types of messaging.
 * @category Layout
 */
export default class Modal extends Component {
    constructor(props) {
        super(props);
        this.containerRef = React.createRef();
        this.buttonRef = React.createRef();
        this.modalRef = React.createRef();
        this.contentRef = React.createRef();
        this.modalHeadingId = uniqueId('modal-heading');
        this.modalContentId = uniqueId('modal-content');
        this.handleKeyPressed = this.handleKeyPressed.bind(this);
        this.handleClick = this.handleClick.bind(this);
        this.closeModal = this.closeModal.bind(this);
        this.addBackdropClickListener = this.addBackdropClickListener.bind(this);
        this.removeBackdropClickListener = this.removeBackdropClickListener.bind(this);
    }

    componentDidMount() {
        const { isOpen } = this.props;
        if (isOpen) {
            this.contentElement = this.contentRef.current;
            CounterManager.increment();
            disableBodyScroll(this.contentRef.current);
            this.modalTriggerElement = document.activeElement;
            this.modalRef.current.focus();
            this.addBackdropClickListener();
        }
    }

    componentDidUpdate(prevProps) {
        const { isOpen, onOpened } = this.props;
        const { isOpen: prevIsOpen } = prevProps;

        const wasOpened = isOpen && !prevIsOpen;
        const wasClosed = !isOpen && prevIsOpen;

        if (wasOpened) {
            CounterManager.increment();
            this.contentElement = this.contentRef.current;
            disableBodyScroll(this.contentRef.current);
            this.modalTriggerElement = document.activeElement;
            this.modalRef.current.focus();
            this.addBackdropClickListener();

            onOpened();
        }

        if (wasClosed) {
            this.removeBackdropClickListener();
            CounterManager.decrement();
            if (this.modalTriggerElement) {
                this.modalTriggerElement.focus();
            }
            if (!CounterManager.hasModalsOpen()) {
                enableBodyScroll(this.contentElement);
                clearAllBodyScrollLocks();
            }
        }
    }

    componentWillUnmount() {
        const { isOpen } = this.props;
        if (isOpen) {
            CounterManager.decrement();
        }
        if (!CounterManager.hasModalsOpen()) {
            enableBodyScroll(this.contentElement);
            clearAllBodyScrollLocks();
        }
        this.removeBackdropClickListener();
    }

    handleKeyPressed(event) {
        event.stopPropagation();
        const { isOpen } = this.props;
        if (
            isOpen &&
            event.keyCode === ESCAPE_KEY &&
            this.containerRef.current.contains(event.target)
        ) {
            this.closeModal();
        }
        if (event.keyCode === TAB_KEY) {
            manageTab(this.modalRef.current, event);
        }
        return null;
    }

    handleClick(event) {
        const { isOpen } = this.props;
        if (isOpen) {
            const isClickOutsideModal = !this.modalRef.current.contains(event.target);
            if (isClickOutsideModal) {
                return this.closeModal();
            }
        }
        return null;
    }

    closeModal() {
        const { onRequestClose } = this.props;
        return onRequestClose();
    }

    addBackdropClickListener() {
        const node = this.containerRef.current;
        if (node) {
            node.addEventListener('click', this.handleClick);
        }
    }

    removeBackdropClickListener() {
        const node = this.containerRef.current;
        if (node) {
            node.removeEventListener('click', this.handleClick);
        }
    }

    render() {
        const {
            title,
            style,
            className,
            children,
            footer,
            isOpen,
            id,
            size,
            hideCloseButton,
            borderRadius,
        } = this.props;

        if (isOpen) {
            return createPortal(
                <StyledBackDrop
                    role="presentation"
                    isOpen={isOpen}
                    id={id}
                    ref={this.containerRef}
                    onKeyDown={this.handleKeyPressed}
                >
                    <StyledModalContainer
                        role="dialog"
                        tabIndex={-1}
                        aria-labelledby={this.modalHeadingId}
                        aria-modal
                        aria-hidden={!isOpen}
                        aria-describedby={this.modalContentId}
                        style={style}
                        ref={this.modalRef}
                        isOpen={isOpen}
                        className={className}
                        size={size}
                        as="section"
                        borderRadius={borderRadius}
                    >
                        <RenderIf isTrue={!hideCloseButton}>
                            <StyledCloseButton
                                id="modal-close-button"
                                icon={<CloseIcon />}
                                title="Close"
                                onClick={this.closeModal}
                                ref={this.buttonRef}
                                borderRadius={borderRadius}
                            />
                        </RenderIf>

                        <Header id={this.modalHeadingId} title={title} />

                        <StyledContent id={this.modalContentId} ref={this.contentRef}>
                            {children}
                        </StyledContent>

                        <RenderIf isTrue={footer}>
                            <StyledFooter>{footer}</StyledFooter>
                        </RenderIf>
                    </StyledModalContainer>
                </StyledBackDrop>,
                document.body,
            );
        }
        return null;
    }
}

Modal.propTypes = {
    /** The title can include text or another component,
     * and is displayed in the header of the component. */
    title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** The size of the Modal. Valid values are small, medium, and large.
     * This value defaults to small. */
    size: PropTypes.oneOf(['small', 'medium', 'large']),
    /** The footer can include text or another component. */
    footer: PropTypes.node,
    /** Controls whether the Modal is opened or not. If true, the modal is open. */
    isOpen: PropTypes.bool,
    /** The action triggered when the component request to close
     *  (e.g click close button, press esc key or click outside the modal). */
    onRequestClose: PropTypes.func,
    /** A callback triggered when the modal is opened. This is useful for example to set focus
     * to an element inside the modal content after it is opened.
     */
    onOpened: PropTypes.func,
    /** The id of the outer element. */
    id: PropTypes.string,
    /** A CSS class for the outer element, in addition to the component's base classes. */
    className: PropTypes.string,
    /** An object with custom style applied to the outer element. */
    style: PropTypes.object,
    /**
     * This prop that should not be visible in the documentation.
     * @ignore
     */
    children: PropTypes.node,
    /** If true, hide the close button in the modal. */
    hideCloseButton: PropTypes.bool,
    /** The border radius of the container. Valid values are square, semi-square, semi-rounded and rounded. This value defaults to rounded. */
    borderRadius: PropTypes.oneOf(['square', 'semi-square', 'semi-rounded', 'rounded']),
};

Modal.defaultProps = {
    isOpen: false,
    title: null,
    size: 'small',
    children: null,
    className: undefined,
    style: undefined,
    footer: null,
    onRequestClose: () => {},
    onOpened: () => {},
    id: undefined,
    hideCloseButton: false,
    borderRadius: 'rounded',
};