nishant-design-system
Version:
Sense UI components library
274 lines (239 loc) • 6.61 kB
Flow
// @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,
);
};