@spaced-out/ui-design-system
Version:
Sense UI components library
371 lines (330 loc) • 9.13 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,
// $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}
/>
);