UNPKG

nishant-design-system

Version:
274 lines (239 loc) 6.61 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, } from '@floating-ui/react-dom-interactions'; import useMountTransition from '../../hooks/useMountTransition'; import {motionDurationNormal} from '../../styles/variables/_motion'; 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, }>; type FooterClassNames = $ReadOnly<{ wrapper?: string, actions?: string, }>; export type ModalProps = { classNames?: ClassNames, children?: React.Node, isOpen?: boolean, onClose?: ?(SyntheticEvent<HTMLElement>) => mixed, hideBackdrop?: boolean, // This prop is removed now removeWhenClosed?: boolean, tapOutsideToClose?: boolean, initialFocus?: number, }; 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, }; 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) => { document.getElementsByTagName('body')[0].classList.add('fixed'); bodyEl.style.overflow = 'hidden'; }; const unfixBody = (bodyEl: HTMLBodyElement) => { document.getElementsByTagName('body')[0].classList.remove('fixed'); 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, }: ModalProps): React.Node => { const {floating, context} = useFloating(); 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 (!isTransitioning && !isOpen) { // 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={floating} aria-hidden={isOpen ? 'false' : 'true'} className={classify( css.modalContainer, { [css.in]: isTransitioning, [css.open]: isOpen, }, classNames?.container, )} > <div className={classify( css.backdrop, { [css.darkBackdrop]: !hideBackdrop, }, classNames?.backdrop, )} onClick={onBackdropClick} /> <div className={classify(css.modal, classNames?.content)} role="dialog"> {children} </div> </div> </FloatingFocusManager>, portalRootRef.current, ); };