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
This commit is contained in:
Charles Bochet
2026-03-04 13:22:31 +01:00
committed by GitHub
parent 995793c0ac
commit 3b2bf39565
99 changed files with 4283 additions and 3688 deletions
@@ -67,10 +67,7 @@ export const AvatarOrIcon = ({
if (isIconInverted || isDefined(IconBackgroundColor)) {
return (
<StyledAvatarOrIconWrapper
isClickable={isClickable}
onClick={onClick}
>
<StyledAvatarOrIconWrapper isClickable={isClickable} onClick={onClick}>
<StyledIconWithBackgroundContainer
backgroundColor={
IconBackgroundColor ?? theme.background.invertedSecondary
@@ -87,10 +84,7 @@ export const AvatarOrIcon = ({
}
return (
<StyledAvatarOrIconWrapper
isClickable={isClickable}
onClick={onClick}
>
<StyledAvatarOrIconWrapper isClickable={isClickable} onClick={onClick}>
<Icon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
@@ -140,8 +140,8 @@ const StyledContainer = styled.div<
`;
const StyledRightComponentDivider = styled.div`
border-left: 1px solid ${themeCssVariables.border.color.light};
align-self: stretch;
border-left: 1px solid ${themeCssVariables.border.color.light};
`;
const renderRightComponent = (
@@ -155,7 +155,7 @@ const renderRightComponent = (
const rendered =
typeof rightComponent === 'function' ? rightComponent() : rightComponent;
if (rightComponentDivider) {
if (rightComponentDivider === true) {
return (
<>
<StyledRightComponentDivider />
@@ -13,4 +13,3 @@ export * from './layout';
export * from './navigation';
export * from './theme';
export * from './utilities';
@@ -41,10 +41,12 @@ const StyledJsonListBase = styled.ul<{
padding: 0;
display: grid;
row-gap: ${themeCssVariables.spacing[2]};
padding-left: ${({ depth }) => (depth > 0 ? themeCssVariables.spacing[8] : '0')};
padding-left: ${({ depth }) =>
depth > 0 ? themeCssVariables.spacing[8] : '0'};
> :first-of-type {
margin-top: ${({ depth }) => (depth > 0 ? themeCssVariables.spacing[2] : '0')};
margin-top: ${({ depth }) =>
depth > 0 ? themeCssVariables.spacing[2] : '0'};
}
`;
+12
View File
@@ -39,6 +39,18 @@ export { Card } from './card/components/Card';
export { CardContent } from './card/components/CardContent';
export { CardFooter } from './card/components/CardFooter';
export { CardHeader } from './card/components/CardHeader';
export { Modal } from './modal/components/Modal';
export { ModalBackdrop } from './modal/components/ModalBackdrop';
export type { ModalContentProps } from './modal/components/ModalContent';
export { ModalContent } from './modal/components/ModalContent';
export type { ModalFooterProps } from './modal/components/ModalFooter';
export { ModalFooter } from './modal/components/ModalFooter';
export type { ModalHeaderProps } from './modal/components/ModalHeader';
export { ModalHeader } from './modal/components/ModalHeader';
export type { ModalOverlay } from './modal/types/ModalOverlay';
export type { ModalPadding } from './modal/types/ModalPadding';
export type { ModalProps } from './modal/types/ModalProps';
export type { ModalSize } from './modal/types/ModalSize';
export {
SectionAlignment,
SectionFontColor,
@@ -0,0 +1,182 @@
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;
};
@@ -0,0 +1,28 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from '@ui/theme-constants';
import { type ModalOverlay } from '../types/ModalOverlay';
const StyledModalBackdrop = styled.div<{
overlay: ModalOverlay;
backdropZIndex: number;
isInContainer?: boolean;
}>`
align-items: center;
background: ${({ overlay, isInContainer }) =>
isInContainer || overlay === 'light'
? themeCssVariables.background.overlayTertiary
: themeCssVariables.background.overlayPrimary};
display: flex;
height: 100%;
justify-content: center;
left: 0;
pointer-events: auto;
position: ${({ isInContainer }) => (isInContainer ? 'absolute' : 'fixed')};
top: 0;
width: 100%;
z-index: ${({ backdropZIndex }) => backdropZIndex};
user-select: none;
`;
export const ModalBackdrop = StyledModalBackdrop;
@@ -0,0 +1,59 @@
import { styled } from '@linaria/react';
import React from 'react';
import { themeCssVariables } from '@ui/theme-constants';
const StyledContent = styled.div<{
isVerticallyCentered?: boolean;
isHorizontallyCentered?: boolean;
noPadding?: boolean;
overflowHidden?: boolean;
gap?: number;
contentPadding?: number;
}>`
align-items: ${({ isVerticallyCentered }) =>
isVerticallyCentered ? 'center' : 'stretch'};
display: flex;
flex: 1 1 0%;
flex-direction: column;
gap: ${({ gap }) =>
gap !== undefined ? `var(--t-spacing-${gap})` : 'unset'};
justify-content: ${({ isHorizontallyCentered }) =>
isHorizontallyCentered ? 'center' : 'flex-start'};
overflow: ${({ overflowHidden }) => (overflowHidden ? 'hidden' : 'visible')};
padding: ${({ noPadding, contentPadding }) => {
if (noPadding === true) return '0';
if (contentPadding !== undefined)
return `var(--t-spacing-${contentPadding})`;
return themeCssVariables.spacing[10];
}};
`;
export type ModalContentProps = React.PropsWithChildren & {
isVerticallyCentered?: boolean;
isHorizontallyCentered?: boolean;
noPadding?: boolean;
overflowHidden?: boolean;
gap?: number;
contentPadding?: number;
};
export const ModalContent = ({
children,
isVerticallyCentered,
isHorizontallyCentered,
noPadding,
overflowHidden,
gap,
contentPadding,
}: ModalContentProps) => (
<StyledContent
isVerticallyCentered={isVerticallyCentered}
isHorizontallyCentered={isHorizontallyCentered}
noPadding={noPadding}
overflowHidden={overflowHidden}
gap={gap}
contentPadding={contentPadding}
>
{children}
</StyledContent>
);
@@ -0,0 +1,43 @@
import { styled } from '@linaria/react';
import React from 'react';
import { themeCssVariables } from '@ui/theme-constants';
const StyledFooter = styled.div<{
autoHeight?: boolean;
centered?: boolean;
smallPadding?: boolean;
}>`
align-items: center;
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
height: ${({ autoHeight }) => (autoHeight ? 'auto' : '60px')};
justify-content: ${({ centered }) => (centered ? 'center' : 'flex-end')};
overflow: hidden;
padding: ${({ smallPadding }) =>
smallPadding ? themeCssVariables.spacing[3] : themeCssVariables.spacing[5]};
`;
export type ModalFooterProps = React.PropsWithChildren & {
autoHeight?: boolean;
centered?: boolean;
smallPadding?: boolean;
className?: string;
};
export const ModalFooter = ({
children,
autoHeight,
centered,
smallPadding,
className,
}: ModalFooterProps) => (
<StyledFooter
autoHeight={autoHeight}
centered={centered}
smallPadding={smallPadding}
className={className}
>
{children}
</StyledFooter>
);
@@ -0,0 +1,65 @@
import { styled } from '@linaria/react';
import React from 'react';
import { MOBILE_VIEWPORT, themeCssVariables } from '@ui/theme-constants';
const StyledHeader = styled.div<{
noPadding?: boolean;
autoHeight?: boolean;
hasBorderBottom?: boolean;
paddingHorizontal?: number;
backgroundColor?: string;
}>`
align-items: center;
display: flex;
flex-direction: row;
flex-shrink: 0;
height: ${({ autoHeight }) => (autoHeight ? 'auto' : '60px')};
overflow: hidden;
padding: ${({ noPadding, paddingHorizontal }) => {
if (paddingHorizontal !== undefined)
return `0 var(--t-spacing-${paddingHorizontal})`;
if (noPadding === true) return '0';
return themeCssVariables.spacing[5];
}};
background-color: ${({ backgroundColor }) => backgroundColor ?? 'unset'};
border-bottom: ${({ hasBorderBottom }) =>
hasBorderBottom
? `1px solid ${themeCssVariables.border.color.medium}`
: 'none'};
@media (max-width: ${MOBILE_VIEWPORT}px) {
${({ paddingHorizontal }) =>
paddingHorizontal !== undefined
? `padding-left: ${themeCssVariables.spacing[4]}; padding-right: ${themeCssVariables.spacing[4]};`
: ''}
}
`;
export type ModalHeaderProps = React.PropsWithChildren & {
noPadding?: boolean;
autoHeight?: boolean;
hasBorderBottom?: boolean;
paddingHorizontal?: number;
backgroundColor?: string;
className?: string;
};
export const ModalHeader = ({
children,
noPadding,
autoHeight,
hasBorderBottom,
paddingHorizontal,
backgroundColor,
className,
}: ModalHeaderProps) => (
<StyledHeader
noPadding={noPadding}
autoHeight={autoHeight}
hasBorderBottom={hasBorderBottom}
paddingHorizontal={paddingHorizontal}
backgroundColor={backgroundColor}
className={className}
>
{children}
</StyledHeader>
);
@@ -0,0 +1,244 @@
import { styled } from '@linaria/react';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { H1Title, H1TitleFontColor, H2Title, IconX } from '@ui/display';
import { Button, IconButton } from '@ui/input';
import { Section, SectionAlignment, SectionFontColor } from '@ui/layout';
import { ComponentDecorator } from '@ui/testing';
import { themeCssVariables } from '@ui/theme-constants';
import { Modal } from '../Modal';
import { ModalContent } from '../ModalContent';
import { ModalFooter } from '../ModalFooter';
import { ModalHeader } from '../ModalHeader';
const StyledCenteredTitle = styled.div`
text-align: center;
`;
const StyledSection = styled(Section)`
margin-bottom: ${themeCssVariables.spacing[6]};
`;
const meta: Meta<typeof Modal> = {
title: 'UI/Layout/Modal/Modal',
component: Modal,
decorators: [ComponentDecorator],
argTypes: {
size: {
control: 'select',
options: ['small', 'medium', 'large', 'extraLarge'],
},
padding: {
control: 'select',
options: ['none', 'small', 'medium', 'large'],
},
overlay: {
control: 'select',
options: ['light', 'dark', 'transparent'],
},
},
};
export default meta;
type Story = StoryObj<typeof Modal>;
export const Default: Story = {
args: {
isOpen: true,
size: 'medium',
padding: 'none',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalHeader>
<H2Title
title="Edit workspace"
description="Update your workspace settings"
/>
</ModalHeader>
<ModalContent>
<Section>
Workspace name and subdomain can be changed from the settings panel.
These changes will be reflected across all members.
</Section>
</ModalContent>
<ModalFooter>
<Button title="Cancel" variant="secondary" />
<Button title="Save" variant="primary" accent="blue" />
</ModalFooter>
</Modal>
),
};
export const Confirmation: Story = {
args: {
isOpen: true,
padding: 'large',
overlay: 'dark',
smallBorderRadius: true,
narrowWidth: true,
autoHeight: true,
gap: 2,
},
render: ({
isOpen,
padding,
overlay,
smallBorderRadius,
narrowWidth,
autoHeight,
gap,
}) => (
<Modal
isOpen={isOpen}
padding={padding}
overlay={overlay}
smallBorderRadius={smallBorderRadius}
narrowWidth={narrowWidth}
autoHeight={autoHeight}
gap={gap}
>
<StyledCenteredTitle>
<H1Title title="Delete record?" fontColor={H1TitleFontColor.Primary} />
</StyledCenteredTitle>
<StyledSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
This action cannot be undone. The record and all of its data will be
permanently removed.
</StyledSection>
<Button title="Cancel" variant="secondary" fullWidth justify="center" />
<Button
title="Delete"
variant="secondary"
accent="danger"
fullWidth
justify="center"
/>
</Modal>
),
};
export const Small: Story = {
args: {
isOpen: true,
size: 'small',
padding: 'none',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalHeader>
<H2Title title="Archive item" />
</ModalHeader>
<ModalContent>
<Section>Are you sure you want to archive this item?</Section>
</ModalContent>
<ModalFooter>
<Button title="No" variant="secondary" />
<Button title="Yes, archive" variant="primary" accent="blue" />
</ModalFooter>
</Modal>
),
};
export const ExtraLarge: Story = {
args: {
isOpen: true,
size: 'extraLarge',
padding: 'none',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalHeader>
<H2Title
title="Import contacts"
description="Upload a CSV file to import your contacts"
/>
</ModalHeader>
<ModalContent>
<Section>
The file should include columns for name, email, phone, and company.
Drag and drop your CSV file here, or click to browse.
</Section>
</ModalContent>
<ModalFooter>
<Button title="Cancel" variant="secondary" />
<Button title="Upload & import" variant="primary" accent="blue" />
</ModalFooter>
</Modal>
),
};
export const Closed: Story = {
args: {
isOpen: false,
size: 'medium',
padding: 'medium',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalContent>This should not be visible.</ModalContent>
</Modal>
),
};
const InteractiveModal = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
title="Open Modal"
variant="primary"
accent="blue"
onClick={() => setIsOpen(true)}
/>
<Modal
isOpen={isOpen}
size="medium"
padding="none"
overlay="dark"
onBackdropMouseDown={() => setIsOpen(false)}
>
<ModalHeader>
<H2Title title="Create record" />
<IconButton
Icon={IconX}
variant="tertiary"
size="small"
onClick={() => setIsOpen(false)}
/>
</ModalHeader>
<ModalContent>
<Section>
Fill in the details below to create a new record. All fields are
optional.
</Section>
</ModalContent>
<ModalFooter>
<Button
title="Cancel"
variant="secondary"
onClick={() => setIsOpen(false)}
/>
<Button
title="Create"
variant="primary"
accent="blue"
onClick={() => setIsOpen(false)}
/>
</ModalFooter>
</Modal>
</>
);
};
export const Interactive: Story = {
render: () => <InteractiveModal />,
};
@@ -0,0 +1,12 @@
export { Modal } from './components/Modal';
export { ModalBackdrop } from './components/ModalBackdrop';
export { ModalContent } from './components/ModalContent';
export type { ModalContentProps } from './components/ModalContent';
export { ModalFooter } from './components/ModalFooter';
export type { ModalFooterProps } from './components/ModalFooter';
export { ModalHeader } from './components/ModalHeader';
export type { ModalHeaderProps } from './components/ModalHeader';
export type { ModalOverlay } from './types/ModalOverlay';
export type { ModalPadding } from './types/ModalPadding';
export type { ModalProps } from './types/ModalProps';
export type { ModalSize } from './types/ModalSize';
@@ -0,0 +1 @@
export type ModalOverlay = 'light' | 'dark' | 'transparent';
@@ -0,0 +1 @@
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
@@ -0,0 +1,26 @@
import type React from 'react';
import { type ModalOverlay } from './ModalOverlay';
import { type ModalPadding } from './ModalPadding';
import { type ModalSize } from './ModalSize';
export type ModalProps = React.PropsWithChildren & {
isOpen: boolean;
size?: ModalSize;
padding?: ModalPadding;
overlay?: ModalOverlay;
isMobile?: boolean;
isInContainer?: boolean;
container?: HTMLElement | null;
gap?: number;
smallBorderRadius?: boolean;
narrowWidth?: boolean;
autoHeight?: boolean;
modalZIndex?: number;
backdropZIndex?: number;
backdropTestId?: string;
backdropClickOutsideId?: string;
preventClickOutside?: boolean;
onBackdropMouseDown?: (e: React.MouseEvent) => void;
modalRef?: React.RefObject<HTMLDivElement>;
};
@@ -0,0 +1 @@
export type ModalSize = 'small' | 'medium' | 'large' | 'extraLarge';
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long