Files
twenty/packages/twenty-ui/src/layout/modal/components/Modal.tsx
T
Charles Bochet 3b2bf39565 Refactor modal (#18377)
## Summary

- Move Modal UI components (`Modal`, `ModalContent`, `ModalHeader`,
`ModalFooter`, `ModalBackdrop`) from `twenty-front` to `twenty-ui` as
stateless, reusable components
- Create `ModalStatefulWrapper` in `twenty-front` that connects Jotai
state (`isModalOpenedComponentState`) to the stateless `Modal` via an
`isOpen` prop
- Rename `modalVariant` prop to `overlay` with clearer values: `'dark'`
(default), `'light'` (in-container), `'transparent'` (invisible panel).
Remove unused `'medium'` overlay
- Rename `modalId` to `modalInstanceId` across the entire modal zone
(~30 consumer files)
- Extract `ModalProps` to its own file in
`twenty-ui/types/ModalProps.ts`; extract `ModalStatefulWrapperProps` to
its own file using `Pick<ModalProps, ...>` for shared props
- Extract `ModalBackdrop` to its own file and export from `twenty-ui`;
use it in `UserOrMetadataLoader` instead of a local styled component
- Use `ModalFooter` in `StepNavigationButton` and `ModalHeader` in
`SpreadsheetImportStepperContainer` instead of duplicated `styled.div`
definitions
- Remove unused `onClose` prop from stateless `Modal`; fix `typeof
document` guard in `ModalStatefulWrapper`
- Split shared types into individual files: `ModalSize.ts`,
`ModalPadding.ts`, `ModalOverlay.ts`
- Extract wyw profiling instrumentation from `vite.config.ts` into
reusable `createWywProfilingPlugin` with parametrized threshold and
improved logging
- Delete old `Modal.tsx`, `Modal.styles.ts`, `ModalContent.tsx`,
`ModalHeader.tsx`, `ModalFooter.tsx` from `twenty-front`
- Add comprehensive Storybook stories in `twenty-ui` covering Default,
Confirmation, Small, ExtraLarge, Closed, and Interactive variants
2026-03-04 13:22:31 +01:00

183 lines
5.3 KiB
TypeScript

import { styled } from '@linaria/react';
import { AnimatePresence, motion } from 'framer-motion';
import React, { useContext, useRef } from 'react';
import { createPortal } from 'react-dom';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from '@ui/theme-constants';
import { ThemeContext } from '@ui/theme';
import { type ModalOverlay } from '../types/ModalOverlay';
import { type ModalPadding } from '../types/ModalPadding';
import { type ModalProps } from '../types/ModalProps';
import { type ModalSize } from '../types/ModalSize';
import { ModalBackdrop } from './ModalBackdrop';
const DEFAULT_MODAL_Z_INDEX = 40;
const DEFAULT_BACKDROP_Z_INDEX = 39;
const StyledModalDiv = styled.div<{
size?: ModalSize;
padding?: ModalPadding;
isMobile: boolean;
overlay: ModalOverlay;
gap?: number;
smallBorderRadius?: boolean;
narrowWidth?: boolean;
autoHeight?: boolean;
modalZIndex: number;
}>`
display: flex;
flex-direction: column;
box-shadow: ${({ overlay }) =>
overlay === 'dark'
? themeCssVariables.boxShadow.superHeavy
: overlay === 'transparent'
? 'none'
: themeCssVariables.boxShadow.strong};
background: ${({ overlay }) =>
overlay === 'transparent'
? 'transparent'
: themeCssVariables.background.primary};
color: ${themeCssVariables.font.color.primary};
border-radius: ${({ isMobile, overlay, smallBorderRadius }) => {
if (isMobile === true || overlay === 'transparent') return '0';
if (smallBorderRadius === true) return themeCssVariables.spacing[1];
return themeCssVariables.border.radius.md;
}};
overflow-x: hidden;
overflow-y: auto;
z-index: ${({ modalZIndex }) => modalZIndex};
gap: ${({ gap }) =>
gap !== undefined ? `var(--t-spacing-${gap})` : 'unset'};
width: ${({ isMobile, size, narrowWidth }) => {
if (narrowWidth === true)
return `calc(400px - ${themeCssVariables.spacing[32]})`;
if (isMobile)
return themeCssVariables.modal.size.fullscreen.width ?? 'auto';
switch (size) {
case 'small':
return themeCssVariables.modal.size.sm.width ?? 'auto';
case 'medium':
return themeCssVariables.modal.size.md.width ?? 'auto';
case 'large':
return themeCssVariables.modal.size.lg.width ?? 'auto';
case 'extraLarge':
return themeCssVariables.modal.size.xl.width ?? 'auto';
default:
return 'auto';
}
}};
padding: ${({ padding }) => {
switch (padding) {
case 'none':
return themeCssVariables.spacing[0];
case 'small':
return themeCssVariables.spacing[2];
case 'medium':
return themeCssVariables.spacing[4];
case 'large':
return themeCssVariables.spacing[6];
default:
return 'auto';
}
}};
height: ${({ isMobile, size, autoHeight }) => {
if (autoHeight === true) return 'auto';
if (isMobile)
return themeCssVariables.modal.size.fullscreen.height ?? 'auto';
switch (size) {
case 'extraLarge':
return themeCssVariables.modal.size.xl.height ?? 'auto';
default:
return 'auto';
}
}};
max-height: ${({ isMobile }) => (isMobile ? 'none' : '90dvh')};
`;
const AnimatedModalDiv = motion.create(StyledModalDiv);
const AnimatedBackdrop = motion.create(ModalBackdrop);
const modalAnimation = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
export const Modal = ({
isOpen,
children,
size = 'medium',
padding = 'medium',
overlay = 'dark',
isMobile = false,
isInContainer = false,
container,
gap,
smallBorderRadius,
narrowWidth,
autoHeight,
modalZIndex = DEFAULT_MODAL_Z_INDEX,
backdropZIndex = DEFAULT_BACKDROP_Z_INDEX,
backdropTestId = 'modal-backdrop',
backdropClickOutsideId,
preventClickOutside,
onBackdropMouseDown,
modalRef: externalRef,
}: ModalProps) => {
const internalRef = useRef<HTMLDivElement>(null);
const resolvedRef = externalRef ?? internalRef;
const { theme } = useContext(ThemeContext);
const handleBackdropMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
onBackdropMouseDown?.(e);
};
const content = (
<AnimatePresence mode="wait">
{isOpen && (
<AnimatedBackdrop
data-testid={backdropTestId}
data-click-outside-id={backdropClickOutsideId}
onMouseDown={handleBackdropMouseDown}
overlay={overlay}
backdropZIndex={backdropZIndex}
isInContainer={isInContainer}
>
<AnimatedModalDiv
ref={resolvedRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
overlay={overlay}
variants={modalAnimation}
transition={{ duration: theme.animation.duration.normal }}
isMobile={isMobile}
gap={gap}
smallBorderRadius={smallBorderRadius}
narrowWidth={narrowWidth}
autoHeight={autoHeight}
modalZIndex={modalZIndex}
data-globally-prevent-click-outside={preventClickOutside}
>
{children}
</AnimatedModalDiv>
</AnimatedBackdrop>
)}
</AnimatePresence>
);
if (isDefined(container)) {
return createPortal(content, container);
}
return content;
};