UNPKG

@spaced-out/ui-design-system

Version:
371 lines (330 loc) 9.13 kB
// @flow strict import * as React from 'react'; // $FlowFixMe[untyped-import] import {createPortal} from 'react-dom'; import { // $FlowFixMe[untyped-import] FloatingFocusManager, // $FlowFixMe[untyped-import] useFloating, // $FlowFixMe[untyped-import] useTransitionStyles, } from '@floating-ui/react'; import useMountTransition from '../../hooks/useMountTransition'; import { motionDurationNormal, motionDurationSlow, } from '../../styles/variables/_motion'; import {opacity0, opacity100} from '../../styles/variables/_opacity'; import {spaceNegHalfFluid} from '../../styles/variables/_space'; import classify from '../../utils/classify'; import {uuid} from '../../utils/helpers'; import {Button} from '../Button/Button'; import {Truncate} from '../Truncate/Truncate'; import css from './Modal.module.css'; type ClassNames = $ReadOnly<{ container?: string, content?: string, backdrop?: string, }>; export type UseTransitionStylesProps = { duration?: number | {open: number, close: number}, initial?: {+[key: string]: mixed}, open?: {+[key: string]: mixed}, close?: {+[key: string]: mixed}, common?: {+[key: string]: mixed}, }; type FooterClassNames = $ReadOnly<{ wrapper?: string, actions?: string, }>; export type ModalProps = { classNames?: ClassNames, children?: React.Node, isOpen?: boolean, onClose?: ?(SyntheticEvent<HTMLElement>) => mixed, hideBackdrop?: boolean, tapOutsideToClose?: boolean, allowBackgroundInteraction?: boolean, initialFocus?: number, customAnimation?: UseTransitionStylesProps, }; export type ModalSize = 'small' | 'medium' | 'large'; export type BaseModalProps = { ...ModalProps, size?: ModalSize, }; export type ModalHeaderProps = { children?: React.Node, hideCloseBtn?: boolean, onCloseButtonClick?: ?(SyntheticEvent<HTMLElement>) => mixed, className?: string, }; export type ModalFooterProps = { children?: React.Node, classNames?: FooterClassNames, }; export type ModalBodyProps = { children?: React.Node, className?: string, }; const DEFAULT_MODAL_ANIMATION = { duration: { open: parseInt(motionDurationSlow), close: parseInt(motionDurationNormal), }, initial: { transform: `translate(${spaceNegHalfFluid}, ${spaceNegHalfFluid}) scale(0.95)`, opacity: opacity0, }, open: { transform: `translate(${spaceNegHalfFluid}, ${spaceNegHalfFluid}) scale(1)`, opacity: opacity100, }, close: { transform: `translate(${spaceNegHalfFluid}, ${spaceNegHalfFluid}) scale(0.95)`, opacity: opacity0, }, }; export const ModalHeader = ({ children, hideCloseBtn, onCloseButtonClick, className, }: ModalHeaderProps): React.Node => ( <> {React.Children.count(children) > 0 && ( <div className={classify(css.modalHeader, className)}> <div className={css.headerContent}> <Truncate>{children}</Truncate> </div> {!hideCloseBtn && ( <Button iconLeftName="xmark" type="ghost" onClick={onCloseButtonClick} ariaLabel="Close Button" ></Button> )} </div> )} </> ); export const ModalBody = ({ children, className, }: ModalBodyProps): React.Node => ( <div className={classify(css.modalBody, className)}>{children}</div> ); export const ModalFooter = ({ children, classNames, }: ModalFooterProps): React.Node => ( <> {React.Children.count(children) > 0 && ( <div className={classify(css.modalFooter, classNames?.wrapper)}> <div className={classify(css.modalFooterActions, classNames?.actions)}> {children} </div> </div> )} </> ); const createPortalRoot = (id: string) => { const modalRoot = document.createElement('div'); modalRoot.setAttribute('id', `modal-root-${id}`); return modalRoot; }; const getModalRoot = (id: string) => document.getElementById(`modal-root-${id}`); function hasChildNode(nodeList) { for (let i = 0, len = nodeList.length; i < len; i++) { if (nodeList[i].firstChild !== null) { return true; } } return false; } const fixBody = (bodyEl: HTMLBodyElement) => { const body = document.getElementsByTagName('body')[0]; if (body && body.classList) { body.classList.add('fixed'); } if (bodyEl) { bodyEl.style.overflow = 'hidden'; } }; const unfixBody = (bodyEl: HTMLBodyElement) => { const body = document.getElementsByTagName('body')[0]; if (body && body.classList) { body.classList.remove('fixed'); } if (bodyEl) { bodyEl.style.overflow = ''; } }; const checkAndAddBodyOverflow = (bodyEl: HTMLBodyElement) => { const nodes = document.querySelectorAll('[id^="modal-root"]'); let isParentModalPresent = false; if (nodes.length) { isParentModalPresent = hasChildNode(nodes); } if (isParentModalPresent) { fixBody(bodyEl); } else { unfixBody(bodyEl); } }; export const Modal = ({ classNames, children, isOpen = false, onClose, hideBackdrop = false, tapOutsideToClose = true, initialFocus = -1, customAnimation, allowBackgroundInteraction = false, // Size is not set to default for backward compatibility. Don't change if you don't know this. size, }: BaseModalProps): React.Node => { const {refs, context} = useFloating({open: isOpen}); const {isMounted, styles} = useTransitionStyles( context, customAnimation || DEFAULT_MODAL_ANIMATION, ); const modalId = uuid(); const bodyRef = React.useRef(document.querySelector('body')); const portalRootRef = React.useRef( getModalRoot(modalId) || createPortalRoot(modalId), ); const isTransitioning = useMountTransition( isOpen, parseInt(motionDurationNormal), ); // Append portal root on mount React.useEffect(() => { bodyRef.current?.appendChild(portalRootRef.current); const portal = portalRootRef.current; const bodyEl = bodyRef.current; return () => { // Clean up the portal when modal component unmounts portal.remove(); // Ensure scroll overflow is removed if (bodyEl) { unfixBody(bodyEl); } }; }, []); // Prevent page scrolling when the modal is open React.useEffect(() => { const updatePageScroll = () => { if (isOpen) { if (bodyRef.current) { fixBody(bodyRef.current); } } else { if (bodyRef.current) { unfixBody(bodyRef.current); } } }; updatePageScroll(); }, [isOpen]); // Allow Escape key to dismiss the modal React.useEffect(() => { const onKeyPress = (e) => { if (e.key === 'Escape') { tapOutsideToClose && onClose && onClose(e); } }; if (isOpen) { window.addEventListener('keyup', onKeyPress); } return () => { window.removeEventListener('keyup', onKeyPress); }; }, [isOpen, onClose]); if (!isOpen && !isMounted) { // Check overflow after resetting the DOM for modal. This should always happen after DOM reset // TODO(Nishant): Better way to do this? setTimeout(() => { if (bodyRef && !!bodyRef.current) { checkAndAddBodyOverflow(bodyRef.current); } }); return null; } const onBackdropClick = (e: SyntheticEvent<HTMLElement>) => { if (tapOutsideToClose && onClose) { onClose(e); } }; return createPortal( <FloatingFocusManager context={context} initialFocus={initialFocus}> <div ref={refs.setFloating} aria-hidden={isOpen ? 'false' : 'true'} className={classify( css.modalContainer, { [css.in]: isTransitioning, [css.open]: isOpen, }, classNames?.container, )} > {!allowBackgroundInteraction && ( <div className={classify( css.backdrop, { [css.darkBackdrop]: !hideBackdrop, }, classNames?.backdrop, )} onClick={onBackdropClick} style={{ opacity: styles?.opacity, transition: styles?.transition, }} /> )} {isMounted && ( <div className={classify( css.modal, { [css.small]: size === 'small', [css.medium]: size === 'medium', [css.large]: size === 'large', }, classNames?.content, )} role="dialog" style={{ // Transition styles ...styles, }} > {children} </div> )} </div> </FloatingFocusManager>, portalRootRef.current, ); }; // FullScreen modal that just calls the Modal component with a wrapper className set which sets the width and height to sizeFluid export const FullScreenModal = ({ classNames, ...props }: ModalProps): React.Node => ( <Modal classNames={{ ...classNames, content: classify(css.fullscreenModalContainer, classNames?.content), }} {...props} /> );