292 lines
8.2 KiB
TypeScript
292 lines
8.2 KiB
TypeScript
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
|
import { ModalHotkeysAndClickOutsideEffect } from '@/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect';
|
|
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
|
|
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
|
|
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
|
|
|
|
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
|
|
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
|
|
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
|
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
|
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
|
import { css, useTheme } from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import React, { useRef } from 'react';
|
|
const StyledModalDiv = styled(motion.div)<{
|
|
size?: ModalSize;
|
|
padding?: ModalPadding;
|
|
isMobile: boolean;
|
|
modalVariant: ModalVariants;
|
|
}>`
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: ${({ theme, modalVariant }) =>
|
|
modalVariant === 'primary'
|
|
? theme.boxShadow.superHeavy
|
|
: theme.boxShadow.strong};
|
|
background: ${({ theme }) => theme.background.primary};
|
|
color: ${({ theme }) => theme.font.color.primary};
|
|
border-radius: ${({ theme, isMobile }) => {
|
|
if (isMobile) return `0`;
|
|
return theme.border.radius.md;
|
|
}};
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
z-index: ${RootStackingContextZIndices.RootModal}; // should be higher than Backdrop's z-index
|
|
|
|
width: ${({ isMobile, size, theme }) => {
|
|
if (isMobile) return theme.modal.size.fullscreen.width;
|
|
switch (size) {
|
|
case 'small':
|
|
return theme.modal.size.sm.width;
|
|
case 'medium':
|
|
return theme.modal.size.md.width;
|
|
case 'large':
|
|
return theme.modal.size.lg.width;
|
|
case 'extraLarge':
|
|
return theme.modal.size.xl.width;
|
|
default:
|
|
return 'auto';
|
|
}
|
|
}};
|
|
|
|
padding: ${({ padding, theme }) => {
|
|
switch (padding) {
|
|
case 'none':
|
|
return theme.spacing(0);
|
|
case 'small':
|
|
return theme.spacing(2);
|
|
case 'medium':
|
|
return theme.spacing(4);
|
|
case 'large':
|
|
return theme.spacing(6);
|
|
default:
|
|
return 'auto';
|
|
}
|
|
}};
|
|
height: ${({ isMobile, theme, size }) => {
|
|
if (isMobile) return theme.modal.size.fullscreen.height;
|
|
|
|
switch (size) {
|
|
case 'extraLarge':
|
|
return theme.modal.size.xl.height;
|
|
default:
|
|
return 'auto';
|
|
}
|
|
}};
|
|
max-height: ${({ isMobile }) => (isMobile ? 'none' : '90dvh')};
|
|
`;
|
|
|
|
const StyledHeader = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
flex-direction: row;
|
|
height: 60px;
|
|
overflow: hidden;
|
|
padding: ${({ theme }) => theme.spacing(5)};
|
|
`;
|
|
|
|
const StyledContent = styled.div<{
|
|
isVerticalCentered?: boolean;
|
|
isHorizontalCentered?: boolean;
|
|
}>`
|
|
display: flex;
|
|
flex: 1;
|
|
flex: 1 1 0%;
|
|
flex-direction: column;
|
|
padding: ${({ theme }) => theme.spacing(10)};
|
|
${({ isVerticalCentered }) =>
|
|
isVerticalCentered &&
|
|
css`
|
|
align-items: center;
|
|
`}
|
|
${({ isHorizontalCentered }) =>
|
|
isHorizontalCentered &&
|
|
css`
|
|
justify-content: center;
|
|
`}
|
|
`;
|
|
|
|
const StyledFooter = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
flex-direction: row;
|
|
height: 60px;
|
|
overflow: hidden;
|
|
padding: ${({ theme }) => theme.spacing(5)};
|
|
`;
|
|
|
|
const StyledBackDrop = styled(motion.div)<{
|
|
modalVariant: ModalVariants;
|
|
}>`
|
|
align-items: center;
|
|
background: ${({ theme, modalVariant }) =>
|
|
modalVariant === 'primary'
|
|
? theme.background.overlayPrimary
|
|
: modalVariant === 'secondary'
|
|
? theme.background.overlaySecondary
|
|
: theme.background.overlayTertiary};
|
|
display: flex;
|
|
height: 100%;
|
|
justify-content: center;
|
|
left: 0;
|
|
position: fixed;
|
|
top: 0;
|
|
width: 100%;
|
|
z-index: ${RootStackingContextZIndices.RootModalBackDrop};
|
|
user-select: none;
|
|
`;
|
|
|
|
type ModalHeaderProps = React.PropsWithChildren & {
|
|
className?: string;
|
|
};
|
|
|
|
const ModalHeader = ({ children, className }: ModalHeaderProps) => (
|
|
<StyledHeader className={className}>{children}</StyledHeader>
|
|
);
|
|
|
|
type ModalContentProps = React.PropsWithChildren & {
|
|
className?: string;
|
|
isVerticalCentered?: boolean;
|
|
isHorizontalCentered?: boolean;
|
|
};
|
|
|
|
const ModalContent = ({
|
|
children,
|
|
className,
|
|
isVerticalCentered,
|
|
isHorizontalCentered,
|
|
}: ModalContentProps) => (
|
|
<StyledContent
|
|
className={className}
|
|
isVerticalCentered={isVerticalCentered}
|
|
isHorizontalCentered={isHorizontalCentered}
|
|
>
|
|
{children}
|
|
</StyledContent>
|
|
);
|
|
type ModalFooterProps = React.PropsWithChildren & {
|
|
className?: string;
|
|
};
|
|
|
|
const ModalFooter = ({ children, className }: ModalFooterProps) => (
|
|
<StyledFooter className={className}>{children}</StyledFooter>
|
|
);
|
|
|
|
export type ModalSize = 'small' | 'medium' | 'large' | 'extraLarge';
|
|
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
|
|
export type ModalVariants = 'primary' | 'secondary' | 'tertiary';
|
|
|
|
export type ModalProps = React.PropsWithChildren & {
|
|
modalId: string;
|
|
size?: ModalSize;
|
|
padding?: ModalPadding;
|
|
className?: string;
|
|
hotkeyScope?: ModalHotkeyScope;
|
|
onEnter?: () => void;
|
|
modalVariant?: ModalVariants;
|
|
dataGloballyPreventClickOutside?: boolean;
|
|
} & (
|
|
| { isClosable: true; onClose?: () => void }
|
|
| { isClosable?: false; onClose?: never }
|
|
);
|
|
|
|
const modalAnimation = {
|
|
hidden: { opacity: 0 },
|
|
visible: { opacity: 1 },
|
|
exit: { opacity: 0 },
|
|
};
|
|
|
|
export const Modal = ({
|
|
modalId,
|
|
children,
|
|
size = 'medium',
|
|
padding = 'medium',
|
|
className,
|
|
onEnter,
|
|
isClosable = false,
|
|
onClose,
|
|
modalVariant = 'primary',
|
|
dataGloballyPreventClickOutside = false,
|
|
}: ModalProps) => {
|
|
const isMobile = useIsMobile();
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
|
|
const theme = useTheme();
|
|
|
|
const stopEventPropagation = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const isModalOpened = useRecoilComponentValueV2(
|
|
isModalOpenedComponentState,
|
|
modalId,
|
|
);
|
|
|
|
const { closeModal } = useModal();
|
|
|
|
const handleClose = () => {
|
|
onClose?.();
|
|
closeModal(modalId);
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
{isModalOpened && (
|
|
<ModalComponentInstanceContext.Provider
|
|
value={{
|
|
instanceId: modalId,
|
|
}}
|
|
>
|
|
<ClickOutsideListenerContext.Provider
|
|
value={{
|
|
excludedClickOutsideId: MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
|
|
}}
|
|
>
|
|
<ModalHotkeysAndClickOutsideEffect
|
|
modalId={modalId}
|
|
modalRef={modalRef}
|
|
onEnter={onEnter}
|
|
isClosable={isClosable}
|
|
onClose={handleClose}
|
|
/>
|
|
<StyledBackDrop
|
|
data-testid="modal-backdrop"
|
|
data-click-outside-id={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
|
|
onMouseDown={stopEventPropagation}
|
|
modalVariant={modalVariant}
|
|
>
|
|
<StyledModalDiv
|
|
ref={modalRef}
|
|
size={size}
|
|
padding={padding}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
layout
|
|
modalVariant={modalVariant}
|
|
variants={modalAnimation}
|
|
transition={{ duration: theme.animation.duration.normal }}
|
|
className={className}
|
|
isMobile={isMobile}
|
|
data-globally-prevent-click-outside={
|
|
dataGloballyPreventClickOutside
|
|
}
|
|
>
|
|
{children}
|
|
</StyledModalDiv>
|
|
</StyledBackDrop>
|
|
</ClickOutsideListenerContext.Provider>
|
|
</ModalComponentInstanceContext.Provider>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
};
|
|
|
|
Modal.Header = ModalHeader;
|
|
Modal.Content = ModalContent;
|
|
Modal.Footer = ModalFooter;
|
|
Modal.Backdrop = StyledBackDrop;
|