From 3b2bf3956558d97367d63d63dae666b7b4e6ebc5 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 4 Mar 2026 13:22:31 +0100 Subject: [PATCH] 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` 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 --- .../components/UserOrMetadataLoader.tsx | 10 +- .../actions/components/ActionModal.tsx | 2 +- .../files/components/AttachmentList.tsx | 35 +- .../src/modules/auth/components/AuthModal.tsx | 10 +- .../auth/components/VerifyEmailEffect.tsx | 6 +- .../__stories__/VerifyEmailEffect.stories.tsx | 8 +- .../modules/auth/constants/AuthModalConfig.ts | 11 +- .../EmailVerificationSent.stories.tsx | 21 +- .../SettingsBillingSubscriptionInfo.tsx | 16 +- .../internal/MeteredPriceSelector.tsx | 2 +- .../CurrentWorkspaceMemberFavorites.tsx | 2 +- ...rentWorkspaceMemberNavigationMenuItems.tsx | 2 +- .../RecordDetailRelationRecordsListItem.tsx | 2 +- .../RecordGroupReorderConfirmationModal.tsx | 2 +- .../RecordIndexRemoveSortingModal.tsx | 2 +- .../SettingsAccountsRowDropdownMenu.tsx | 2 +- ...ttingsAdminDeleteJobsConfirmationModal.tsx | 6 +- .../SettingsAdminQueueJobsTable.tsx | 4 +- ...ettingsAdminRetryJobsConfirmationModal.tsx | 6 +- .../components/tabs/ObjectSettings.tsx | 2 +- .../SettingsDevelopersWebhookForm.tsx | 2 +- .../components/MemberPermissionsTab.tsx | 2 +- .../profile/components/DeleteAccount.tsx | 4 +- .../profile/components/DeleteWorkspace.tsx | 2 +- ...ettingsRoleAssignmentConfirmationModal.tsx | 2 +- ...oleSettingsDeleteRoleConfirmationModal.tsx | 2 +- .../DeleteTwoFactorAuthenticationMethod.tsx | 2 +- .../SpreadSheetImportModalWrapper.tsx | 29 +- .../components/StepNavigationButton.tsx | 9 +- .../provider/components/SpreadsheetImport.tsx | 2 +- .../steps/components/ImportDataStep.tsx | 13 +- .../MatchColumnsStep/MatchColumnsStep.tsx | 11 +- .../SelectHeaderStep/SelectHeaderStep.tsx | 6 +- .../SelectSheetStep/SelectSheetStep.tsx | 14 +- .../components/SpreadsheetImportStepper.tsx | 13 +- .../SpreadsheetImportStepperContainer.tsx | 26 +- .../components/UploadStep/UploadStep.tsx | 12 +- .../ValidationStep/ValidationStep.tsx | 102 +- .../__stories__/MatchColumns.stories.tsx | 2 +- .../__stories__/SelectHeader.stories.tsx | 2 +- .../__stories__/SelectSheet.stories.tsx | 2 +- .../components/__stories__/Upload.stories.tsx | 5 +- .../__stories__/Validation.stories.tsx | 2 +- .../components/GlobalFilePreviewModal.tsx | 10 +- .../modal/components/ConfirmationModal.tsx | 43 +- .../ui/layout/modal/components/Modal.tsx | 322 --- .../ModalHotkeysAndClickOutsideEffect.tsx | 10 +- .../modal/components/ModalStatefulWrapper.tsx | 96 + .../__stories__/ConfirmationModal.stories.tsx | 12 +- .../components/__stories__/Modal.stories.tsx | 40 +- .../ui/layout/modal/hooks/useModal.tsx | 36 +- .../modal/types/ModalStatefulWrapperProps.ts | 23 + ...OverrideWorkflowDraftConfirmationModal.tsx | 2 +- .../src/pages/auth/PasswordReset.tsx | 6 +- .../twenty-front/src/pages/auth/SignInUp.tsx | 10 +- .../src/pages/onboarding/BookCall.tsx | 28 +- .../src/pages/onboarding/BookCallDecision.tsx | 10 +- .../src/pages/onboarding/ChooseYourPlan.tsx | 6 +- .../src/pages/onboarding/CreateProfile.tsx | 6 +- .../src/pages/onboarding/CreateWorkspace.tsx | 6 +- .../src/pages/onboarding/InviteTeam.tsx | 6 +- .../src/pages/onboarding/PaymentSuccess.tsx | 11 +- .../src/pages/onboarding/SyncEmails.tsx | 6 +- .../SettingsAdminConfigVariableDetails.tsx | 2 +- .../pages/settings/ai/SettingsSkillForm.tsx | 2 +- .../SettingsAgentDeleteConfirmationModal.tsx | 2 +- .../ai/components/SettingsAgentEvalsTab.tsx | 2 +- ...SettingsApplicationRegistrationDetails.tsx | 4 +- .../SettingsApplicationDetailAboutTab.tsx | 2 +- .../data-model/SettingsObjectFieldEdit.tsx | 2 +- .../SettingsDevelopersApiKeyDetail.tsx | 4 +- .../pages/settings/domains/SettingsDomain.tsx | 2 +- .../members/SettingsWorkspaceMember.tsx | 2 +- packages/twenty-front/vite.config.ts | 11 +- packages/twenty-shared/package.json | 9 + packages/twenty-shared/project.json | 2 + .../src/vite/createWywProfilingPlugin.ts | 98 + packages/twenty-shared/src/vite/index.ts | 10 + packages/twenty-ui/eslint.config.mjs | 1 + .../avatar-or-icon/AvatarOrIcon.tsx | 10 +- .../twenty-ui/src/components/chip/Chip.tsx | 4 +- packages/twenty-ui/src/individual-entry.ts | 1 - .../components/JsonNestedNode.tsx | 6 +- packages/twenty-ui/src/layout/index.ts | 12 + .../src/layout/modal/components/Modal.tsx | 182 ++ .../layout/modal/components/ModalBackdrop.tsx | 28 + .../layout/modal/components/ModalContent.tsx | 59 + .../layout/modal/components/ModalFooter.tsx | 43 + .../layout/modal/components/ModalHeader.tsx | 65 + .../components/__stories__/Modal.stories.tsx | 244 ++ packages/twenty-ui/src/layout/modal/index.ts | 12 + .../src/layout/modal/types/ModalOverlay.ts | 1 + .../src/layout/modal/types/ModalPadding.ts | 1 + .../src/layout/modal/types/ModalProps.ts | 26 + .../src/layout/modal/types/ModalSize.ts | 1 + .../generated/themeCssVariables.ts | 1983 ++++++++-------- .../generated/themeDarkCssVariableEntries.ts | 2015 +++++++++-------- .../generated/themeLightCssVariableEntries.ts | 2009 ++++++++-------- packages/twenty-ui/vite.config.ts | 8 +- 99 files changed, 4283 insertions(+), 3688 deletions(-) delete mode 100644 packages/twenty-front/src/modules/ui/layout/modal/components/Modal.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/modal/components/ModalStatefulWrapper.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/modal/types/ModalStatefulWrapperProps.ts create mode 100644 packages/twenty-shared/src/vite/createWywProfilingPlugin.ts create mode 100644 packages/twenty-shared/src/vite/index.ts create mode 100644 packages/twenty-ui/src/layout/modal/components/Modal.tsx create mode 100644 packages/twenty-ui/src/layout/modal/components/ModalBackdrop.tsx create mode 100644 packages/twenty-ui/src/layout/modal/components/ModalContent.tsx create mode 100644 packages/twenty-ui/src/layout/modal/components/ModalFooter.tsx create mode 100644 packages/twenty-ui/src/layout/modal/components/ModalHeader.tsx create mode 100644 packages/twenty-ui/src/layout/modal/components/__stories__/Modal.stories.tsx create mode 100644 packages/twenty-ui/src/layout/modal/index.ts create mode 100644 packages/twenty-ui/src/layout/modal/types/ModalOverlay.ts create mode 100644 packages/twenty-ui/src/layout/modal/types/ModalPadding.ts create mode 100644 packages/twenty-ui/src/layout/modal/types/ModalProps.ts create mode 100644 packages/twenty-ui/src/layout/modal/types/ModalSize.ts diff --git a/packages/twenty-front/src/loading/components/UserOrMetadataLoader.tsx b/packages/twenty-front/src/loading/components/UserOrMetadataLoader.tsx index db526b9472..aba9881ac6 100644 --- a/packages/twenty-front/src/loading/components/UserOrMetadataLoader.tsx +++ b/packages/twenty-front/src/loading/components/UserOrMetadataLoader.tsx @@ -1,9 +1,10 @@ import { styled } from '@linaria/react'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints'; import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; +import { ModalBackdrop } from 'twenty-ui/layout'; import { LeftPanelSkeletonLoader } from '~/loading/components/LeftPanelSkeletonLoader'; import { RightPanelSkeletonLoader } from '~/loading/components/RightPanelSkeletonLoader'; @@ -29,7 +30,12 @@ export const UserOrMetadataLoader = () => { return ( - {showAuthModal && } + {showAuthModal && ( + + )} diff --git a/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx b/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx index 6895f3e013..01c119b3b2 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx @@ -65,7 +65,7 @@ export const ActionModal = ({ {isModalOpened && ( - + {previewedAttachment.name} @@ -256,11 +245,11 @@ export const AttachmentList = ({ /> - + - + @@ -275,9 +264,9 @@ export const AttachmentList = ({ documentUrl={getAttachmentUrl(previewedAttachment)} /> - + - , + , document.body, )} diff --git a/packages/twenty-front/src/modules/auth/components/AuthModal.tsx b/packages/twenty-front/src/modules/auth/components/AuthModal.tsx index 612bce2fbd..72afad4e5a 100644 --- a/packages/twenty-front/src/modules/auth/components/AuthModal.tsx +++ b/packages/twenty-front/src/modules/auth/components/AuthModal.tsx @@ -1,7 +1,7 @@ import { AuthModalMountEffect } from '@/auth/components/AuthModalMountEffect'; import { AUTH_MODAL_ID } from '@/auth/constants/AuthModalId'; import { getAuthModalConfig } from '@/auth/utils/getAuthModalConfig'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { styled } from '@linaria/react'; import React from 'react'; @@ -23,11 +23,11 @@ export const AuthModal = ({ children }: AuthModalProps) => { return ( <> - {config.showScrollWrapper ? ( @@ -36,7 +36,7 @@ export const AuthModal = ({ children }: AuthModalProps) => { ) : ( <>{children} )} - + ); }; diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx index 8f87ee9eb4..02ff8e5192 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx @@ -8,7 +8,7 @@ import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useLingui } from '@lingui/react/macro'; import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; @@ -126,9 +126,9 @@ export const VerifyEmailEffect = () => { if (isError) { return ( - + - + ); } diff --git a/packages/twenty-front/src/modules/auth/components/__stories__/VerifyEmailEffect.stories.tsx b/packages/twenty-front/src/modules/auth/components/__stories__/VerifyEmailEffect.stories.tsx index 60edafa73d..b1f9f3320c 100644 --- a/packages/twenty-front/src/modules/auth/components/__stories__/VerifyEmailEffect.stories.tsx +++ b/packages/twenty-front/src/modules/auth/components/__stories__/VerifyEmailEffect.stories.tsx @@ -5,14 +5,14 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; // Mock component that just renders the error state of VerifyEmailEffect directly // (since normal VerifyEmailEffect has async logic that's hard to test in Storybook) import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; const VerifyEmailEffectErrorState = ({ email = 'user@example.com' }) => { return ( - + - + ); }; @@ -29,7 +29,7 @@ const meta: Meta = { ], parameters: { codeSection: { - docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmailEffect, always wrap it with Modal.Content to maintain consistent styling.', + docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmailEffect, always wrap it with ModalContent to maintain consistent styling.', }, }, }; diff --git a/packages/twenty-front/src/modules/auth/constants/AuthModalConfig.ts b/packages/twenty-front/src/modules/auth/constants/AuthModalConfig.ts index 31b58c79ce..2f6ec34b72 100644 --- a/packages/twenty-front/src/modules/auth/constants/AuthModalConfig.ts +++ b/packages/twenty-front/src/modules/auth/constants/AuthModalConfig.ts @@ -1,12 +1,9 @@ -import { - type ModalSize, - type ModalVariants, -} from '@/ui/layout/modal/components/Modal'; +import { type ModalOverlay, type ModalSize } from 'twenty-ui/layout'; import { AppPath } from 'twenty-shared/types'; type AuthModalConfigType = { size: ModalSize; - variant: ModalVariants; + overlay: ModalOverlay; showScrollWrapper: boolean; }; @@ -16,12 +13,12 @@ export const AUTH_MODAL_CONFIG: { } = { default: { size: 'medium', - variant: 'primary', + overlay: 'dark', showScrollWrapper: true, }, [AppPath.BookCall]: { size: 'extraLarge', - variant: 'transparent', + overlay: 'transparent', showScrollWrapper: false, }, }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/EmailVerificationSent.stories.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/EmailVerificationSent.stories.tsx index 2c88fe0c08..7da27a7c05 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/EmailVerificationSent.stories.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/EmailVerificationSent.stories.tsx @@ -1,24 +1,17 @@ import { type Meta, type StoryObj } from '@storybook/react-vite'; import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { ComponentDecorator } from 'twenty-ui/testing'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; -// Wrap the component in Modal.Content to reflect how it's used in the app -const RenderWithModal = ( +const RenderWithModalContent = ( args: React.ComponentProps, ) => { return ( - - - - - + + + ); }; @@ -28,10 +21,10 @@ const meta: Meta = { decorators: [ComponentDecorator, SnackBarDecorator], parameters: { codeSection: { - docs: 'This component should always be wrapped with Modal.Content in the app.\n\nCorrect usage:\n```tsx\n\n \n\n```\n', + docs: 'This component should always be wrapped with ModalContent in the app.\n\nCorrect usage:\n```tsx\n\n \n\n```\n', }, }, - render: RenderWithModal, + render: RenderWithModalContent, }; export default meta; diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingSubscriptionInfo.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingSubscriptionInfo.tsx index c9c4ec21b7..c525bda7c0 100644 --- a/packages/twenty-front/src/modules/billing/components/SettingsBillingSubscriptionInfo.tsx +++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingSubscriptionInfo.tsx @@ -507,7 +507,7 @@ export const SettingsBillingSubscriptionInfo = ({ )} 1 ? t`Remove ${favoriteCount} favorites?` diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx index ce403cb489..2383ffb7ce 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx @@ -317,7 +317,7 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ {isModalOpened && createPortal( 1 ? t`Remove ${navigationMenuItemCount} navigation menu items?` diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx index 688f6271e3..3808efe9c5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx @@ -278,7 +278,7 @@ export const RecordDetailRelationRecordsListItem = ({ {createPortal( diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupReorderConfirmationModal.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupReorderConfirmationModal.tsx index 9977208905..941b093676 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupReorderConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupReorderConfirmationModal.tsx @@ -21,7 +21,7 @@ export const RecordGroupReorderConfirmationModal = ({ <> {createPortal( { return ( diff --git a/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminDeleteJobsConfirmationModal.tsx b/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminDeleteJobsConfirmationModal.tsx index dadb58cc66..f404950b88 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminDeleteJobsConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminDeleteJobsConfirmationModal.tsx @@ -2,14 +2,14 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa import { plural, t } from '@lingui/core/macro'; type SettingsAdminDeleteJobsConfirmationModalProps = { - modalId: string; + modalInstanceId: string; jobCount: number; onConfirm: () => void; onClose?: () => void; }; export const SettingsAdminDeleteJobsConfirmationModal = ({ - modalId, + modalInstanceId, jobCount, onConfirm, onClose, @@ -26,7 +26,7 @@ export const SettingsAdminDeleteJobsConfirmationModal = ({ return ( 0 ? selectedCount : failedJobs.length} onConfirm={confirmRetrySelected} /> diff --git a/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminRetryJobsConfirmationModal.tsx b/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminRetryJobsConfirmationModal.tsx index d0c895f0ac..d37fdca29a 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminRetryJobsConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/health-status/components/SettingsAdminRetryJobsConfirmationModal.tsx @@ -2,14 +2,14 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa import { plural, t } from '@lingui/core/macro'; type SettingsAdminRetryJobsConfirmationModalProps = { - modalId: string; + modalInstanceId: string; jobCount: number; onConfirm: () => void; onClose?: () => void; }; export const SettingsAdminRetryJobsConfirmationModal = ({ - modalId, + modalInstanceId, jobCount, onConfirm, onClose, @@ -26,7 +26,7 @@ export const SettingsAdminRetryJobsConfirmationModal = ({ return ( )} diff --git a/packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx b/packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx index 028e204a20..1fbe4f3897 100644 --- a/packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx +++ b/packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx @@ -150,7 +150,7 @@ export const MemberPermissionsTab = ({ {pendingRole && ( { @@ -109,7 +109,7 @@ export const DeleteAccount = () => { diff --git a/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx b/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx index c5cc85691b..52c01672be 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/DeleteWorkspace.tsx @@ -43,7 +43,7 @@ export const DeleteWorkspace = () => { /> { void; }; export const SpreadSheetImportModalWrapper = ({ - modalId, + modalInstanceId, children, onClose, }: SpreadSheetImportModalWrapperProps) => { const { rtl } = useSpreadsheetImportInternal(); return ( - - - - {children} - - + + + + {children} + + + ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx index 1e64f28dc1..332549dc60 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx @@ -1,19 +1,16 @@ import { styled } from '@linaria/react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { Modal } from '@/ui/layout/modal/components/Modal'; import { t } from '@lingui/core/macro'; import { CircularProgressBar } from 'twenty-ui/feedback'; import { MainButton } from 'twenty-ui/input'; +import { ModalFooter } from 'twenty-ui/layout'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -const StyledFooter = styled(Modal.Footer)` +const StyledFooter = styled(ModalFooter)` border-top: 1px solid ${themeCssVariables.border.color.medium}; box-shadow: ${themeCssVariables.boxShadow.strong}; - gap: 10px; justify-content: space-between; - padding: ${themeCssVariables.spacing[4]}; - height: auto; `; type StepNavigationButtonProps = { @@ -34,7 +31,7 @@ export const StepNavigationButton = ({ isContinueDisabled = false, }: StepNavigationButtonProps) => { return ( - + {!isUndefinedOrNull(onBack) && ( { return ( diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ImportDataStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ImportDataStep.tsx index 496797e0bf..05cca8ce0c 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ImportDataStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ImportDataStep.tsx @@ -3,19 +3,12 @@ import { StepNavigationButton } from '@/spreadsheet-import/components/StepNaviga import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-import/states/spreadsheetImportCreatedRecordsProgressState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { styled } from '@linaria/react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { t } from '@lingui/core/macro'; import { Loader } from 'twenty-ui/feedback'; -const StyledContent = styled(Modal.Content)` - align-items: center; - display: flex; - justify-content: center; - padding: 0px; -`; - const StyledHeader = styled.span` color: ${themeCssVariables.font.color.primary}; font-size: ${themeCssVariables.font.size.md}; @@ -50,11 +43,11 @@ export const ImportDataStep = ({ return ( <> - + {t`Importing Data ...`} {t`${formattedCreatedRecordsProgress} out of ${formattedRecordsToImportCount} records imported.`} - + ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx index eefa904c91..eccf420a6b 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx @@ -14,7 +14,7 @@ import { setIgnoreColumn } from '@/spreadsheet-import/utils/setIgnoreColumn'; import { setSubColumn } from '@/spreadsheet-import/utils/setSubColumn'; import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey'; import { ColumnGrid } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/ColumnGrid'; @@ -33,11 +33,6 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { Trans, useLingui } from '@lingui/react/macro'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -const StyledContent = styled(Modal.Content)` - align-items: center; - padding: 0px; -`; - const StyledColumnsContainer = styled.div` align-items: center; display: flex; @@ -276,7 +271,7 @@ export const MatchColumnsStep = ({ return ( <> - + - + - + - + - + setValue(value)} value={value}> @@ -124,7 +116,7 @@ export const SelectSheetStep = ({ ))} - + handleOnContinue(value)} onBack={onBack} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx index 37c16883c6..3cf1083d63 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx @@ -1,10 +1,9 @@ -import { styled } from '@linaria/react'; import { useCallback, useContext, useState } from 'react'; import { ThemeContext } from 'twenty-ui/theme'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { ImportDataStep } from '@/spreadsheet-import/steps/components/ImportDataStep'; import { type SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; @@ -16,12 +15,6 @@ import { SelectSheetStep } from './SelectSheetStep/SelectSheetStep'; import { UploadStep } from './UploadStep/UploadStep'; import { ValidationStep } from './ValidationStep/ValidationStep'; -const StyledProgressBarContainer = styled(Modal.Content)` - align-items: center; - display: flex; - justify-content: center; -`; - type SpreadsheetImportStepperProps = { nextStep: () => void; prevStep: () => void; @@ -137,13 +130,13 @@ export const SpreadsheetImportStepper = ({ case SpreadsheetImportStepType.loading: default: return ( - + - + ); } }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer.tsx index 8c593ed396..0386f0f749 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer.tsx @@ -1,5 +1,3 @@ -import { styled } from '@linaria/react'; - import { useSpreadsheetImportInitialStep } from '@/spreadsheet-import/hooks/useSpreadsheetImportInitialStep'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; @@ -7,24 +5,12 @@ import { StepBar } from '@/ui/navigation/step-bar/components/StepBar'; import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar'; import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; -import { Modal } from '@/ui/layout/modal/components/Modal'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useLingui } from '@lingui/react/macro'; -import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { ModalHeader } from 'twenty-ui/layout'; import { SpreadsheetImportStepper } from './SpreadsheetImportStepper'; -const StyledHeader = styled(Modal.Header)` - background-color: ${themeCssVariables.background.secondary}; - border-bottom: 1px solid ${themeCssVariables.border.color.medium}; - padding: 0px ${themeCssVariables.spacing[30]}; - height: 60px; - flex-shrink: 0; - @media (max-width: ${MOBILE_VIEWPORT}px) { - padding-left: ${themeCssVariables.spacing[4]}; - padding-right: ${themeCssVariables.spacing[4]}; - } -`; - export const SpreadsheetImportStepperContainer = () => { const { t } = useLingui(); @@ -50,7 +36,11 @@ export const SpreadsheetImportStepperContainer = () => { return ( <> - + {spreadsheetImportDialog.isStepBarVisible && ( {steps.map((key) => ( @@ -62,7 +52,7 @@ export const SpreadsheetImportStepperContainer = () => { ))} )} - + ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx index c911280664..18e47f28fe 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx @@ -1,10 +1,8 @@ import { t } from '@lingui/core/macro'; -import { styled } from '@linaria/react'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; import { useCallback, useState } from 'react'; import { type WorkBook } from 'xlsx-ugnis'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useComputeColumnSuggestionsAndAutoMatch } from '@/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; @@ -14,10 +12,6 @@ import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords' import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook'; import { DropZone } from './components/DropZone'; -const StyledContent = styled(Modal.Content)` - padding: ${themeCssVariables.spacing[6]}; -`; - type UploadStepProps = { setUploadedFile: (file: File) => void; setCurrentStepState: (data: any) => void; @@ -118,8 +112,8 @@ export const UploadStep = ({ ); return ( - + - + ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx index 1dcdd5cca8..5018501ad5 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx @@ -12,7 +12,7 @@ import { type SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetC import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType'; import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations'; import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { styled } from '@linaria/react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { Trans, useLingui } from '@lingui/react/macro'; @@ -31,8 +31,10 @@ import { Button, Toggle } from 'twenty-ui/input'; import { generateColumns } from './components/columns'; import { type ImportedStructuredRowMetadata } from './types'; -const StyledContent = styled(Modal.Content)` - padding: 0px; +const StyledContentWrapper = styled.div` + display: flex; + flex: 1 1 0%; + flex-direction: column; position: relative; `; @@ -282,53 +284,55 @@ export const ValidationStep = ({ return ( <> - - {filterByErrors && tableData.length === 0 ? ( - - No rows with errors - - ) : ( - - - {filterByErrors - ? t`No data containing errors` - : t`No data found`} - - ), - }} + + + {filterByErrors && tableData.length === 0 ? ( + + No rows with errors + + ) : ( + + + {filterByErrors + ? t`No data containing errors` + : t`No data found`} + + ), + }} + /> + + )} + + + setFilterByErrors(!filterByErrors)} + toggleSize="small" + /> + + Show only rows with errors + + + - - )} - - - setFilterByErrors(!filterByErrors)} - toggleSize="small" - /> - - Show only rows with errors - - - - - + + + ( > null} > ( > null} > ( > null} > ( value={{ instanceId: 'dialog-manager' }} > - null}> + null} + > null} setCurrentStepState={() => null} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx index 8a16f33a66..815287dac9 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx @@ -46,7 +46,7 @@ export const Default = () => ( > null} > { return ( <> {createPortal( - @@ -141,7 +141,7 @@ export const GlobalFilePreviewModal = (): JSX.Element | null => { - , + , document.body, )} diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx index d68cb6abea..d533b09ff2 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx @@ -4,17 +4,22 @@ import { useDebouncedCallback } from 'use-debounce'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; -import { Modal, type ModalVariants } from '@/ui/layout/modal/components/Modal'; +import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react/macro'; import { H1Title, H1TitleFontColor } from 'twenty-ui/display'; import { Button, type ButtonAccent } from 'twenty-ui/input'; -import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout'; +import { + Section, + SectionAlignment, + SectionFontColor, + type ModalOverlay, +} from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; export type ConfirmationModalProps = { - modalId: string; + modalInstanceId: string; title: string; loading?: boolean; subtitle: ReactNode; @@ -25,18 +30,11 @@ export type ConfirmationModalProps = { confirmationValue?: string; confirmButtonAccent?: ButtonAccent; AdditionalButtons?: React.ReactNode; - modalVariant?: ModalVariants; + overlay?: ModalOverlay; }; -const StyledConfirmationModal = styled(Modal)` - border-radius: ${themeCssVariables.spacing[1]}; - width: calc(400px - ${themeCssVariables.spacing[32]}); - height: auto; -`; - export const StyledCenteredButton = styled(Button)` box-sizing: border-box; - justify-content: center; margin-top: ${themeCssVariables.spacing[2]}; `; @@ -62,7 +60,7 @@ export const StyledConfirmationButton = styled(StyledCenteredButton)` const defaultConfirmButtonText = msg`Confirm`; export const ConfirmationModal = ({ - modalId, + modalInstanceId, title, loading, subtitle, @@ -73,7 +71,7 @@ export const ConfirmationModal = ({ confirmationPlaceholder, confirmButtonAccent = 'danger', AdditionalButtons, - modalVariant = 'primary', + overlay = 'dark', }: ConfirmationModalProps) => { const { i18n, t } = useLingui(); const translatedConfirmButtonText = @@ -97,12 +95,12 @@ export const ConfirmationModal = ({ const { closeModal } = useModal(); const handleConfirmClick = () => { - closeModal(modalId); + closeModal(modalInstanceId); onConfirmClick(); }; const handleCancelClick = () => { - closeModal(modalId); + closeModal(modalInstanceId); onClose?.(); }; @@ -113,17 +111,20 @@ export const ConfirmationModal = ({ }; return ( - { onClose?.(); }} onEnter={handleEnter} isClosable={true} padding="large" - modalVariant={modalVariant} + overlay={overlay} dataGloballyPreventClickOutside - ignoreContainer + renderInDocumentBody + smallBorderRadius + narrowWidth + autoHeight > @@ -153,6 +154,7 @@ export const ConfirmationModal = ({ variant="secondary" title={t`Cancel`} fullWidth + justify="center" dataTestId="confirmation-modal-cancel-button" /> @@ -165,8 +167,9 @@ export const ConfirmationModal = ({ title={translatedConfirmButtonText} disabled={!isValidValue || loading} fullWidth + justify="center" dataTestId="confirmation-modal-confirm-button" /> - + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/Modal.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/Modal.tsx deleted file mode 100644 index fd72d1e253..0000000000 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/Modal.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; -import { ModalHotkeysAndClickOutsideEffect } from '@/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect'; -import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext'; -import { useModalContainer } from '@/ui/layout/modal/contexts/ModalContainerContext'; -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 { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -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 'twenty-ui/theme-constants'; -import { ThemeContext } from 'twenty-ui/theme'; -const StyledModalDivBase = styled.div<{ - size?: ModalSize; - padding?: ModalPadding; - isMobile: boolean; - modalVariant: ModalVariants; -}>` - display: flex; - flex-direction: column; - box-shadow: ${({ modalVariant }) => - modalVariant === 'primary' - ? themeCssVariables.boxShadow.superHeavy - : modalVariant === 'transparent' - ? 'none' - : themeCssVariables.boxShadow.strong}; - background: ${({ modalVariant }) => - modalVariant === 'transparent' - ? 'transparent' - : themeCssVariables.background.primary}; - color: ${themeCssVariables.font.color.primary}; - border-radius: ${({ isMobile, modalVariant }) => { - if (isMobile || modalVariant === 'transparent') return `0`; - return themeCssVariables.border.radius.md; - }}; - overflow-x: hidden; - overflow-y: auto; - z-index: ${RootStackingContextZIndices.RootModal}; // should be higher than Backdrop's z-index - - width: ${({ isMobile, size }) => { - 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 }) => { - 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 StyledModalDiv = motion.create(StyledModalDivBase); - -const StyledHeader = styled.div` - align-items: center; - display: flex; - flex-direction: row; - height: 60px; - overflow: hidden; - padding: ${themeCssVariables.spacing[5]}; -`; - -const StyledContent = styled.div<{ - isVerticalCentered?: boolean; - isHorizontalCentered?: boolean; -}>` - display: flex; - flex: 1; - flex: 1 1 0%; - flex-direction: column; - padding: ${themeCssVariables.spacing[10]}; - align-items: ${({ isVerticalCentered }) => - isVerticalCentered ? 'center' : 'stretch'}; - justify-content: ${({ isHorizontalCentered }) => - isHorizontalCentered ? 'center' : 'flex-start'}; -`; - -const StyledFooter = styled.div` - align-items: center; - display: flex; - flex-direction: row; - height: 60px; - overflow: hidden; - padding: ${themeCssVariables.spacing[5]}; -`; - -const StyledBackDropBase = styled.div<{ - modalVariant: ModalVariants; - isInContainer?: boolean; -}>` - align-items: center; - background: ${({ modalVariant, isInContainer }) => - isInContainer - ? themeCssVariables.background.overlayTertiary - : modalVariant === 'primary' || modalVariant === 'transparent' - ? themeCssVariables.background.overlayPrimary - : modalVariant === 'secondary' - ? themeCssVariables.background.overlaySecondary - : themeCssVariables.background.overlayTertiary}; - display: flex; - height: 100%; - justify-content: center; - left: 0; - pointer-events: auto; - position: ${({ isInContainer }) => (isInContainer ? 'absolute' : 'fixed')}; - top: 0; - width: 100%; - z-index: ${RootStackingContextZIndices.RootModalBackDrop}; - user-select: none; -`; -const StyledBackDrop = motion.create(StyledBackDropBase); - -type ModalHeaderProps = React.PropsWithChildren & { - className?: string; -}; - -const ModalHeader = ({ children, className }: ModalHeaderProps) => ( - {children} -); - -type ModalContentProps = React.PropsWithChildren & { - className?: string; - isVerticalCentered?: boolean; - isHorizontalCentered?: boolean; -}; - -const ModalContent = ({ - children, - className, - isVerticalCentered, - isHorizontalCentered, -}: ModalContentProps) => ( - - {children} - -); -type ModalFooterProps = React.PropsWithChildren & { - className?: string; -}; - -const ModalFooter = ({ children, className }: ModalFooterProps) => ( - {children} -); - -export type ModalSize = 'small' | 'medium' | 'large' | 'extraLarge'; -export type ModalPadding = 'none' | 'small' | 'medium' | 'large'; -export type ModalVariants = - | 'primary' - | 'secondary' - | 'tertiary' - | 'transparent'; - -export type ModalProps = React.PropsWithChildren & { - modalId: string; - size?: ModalSize; - padding?: ModalPadding; - className?: string; - onEnter?: () => void; - modalVariant?: ModalVariants; - dataGloballyPreventClickOutside?: boolean; - shouldCloseModalOnClickOutsideOrEscape?: boolean; - ignoreContainer?: 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, - shouldCloseModalOnClickOutsideOrEscape = true, - ignoreContainer = false, -}: ModalProps) => { - const isMobile = useIsMobile(); - const modalRef = useRef(null); - const { container } = useModalContainer(); - const effectiveContainer = ignoreContainer - ? isDefined(document) - ? document.body - : null - : container; - const isInContainer = isDefined(container) && !ignoreContainer; - - const { theme } = useContext(ThemeContext); - - const stopEventPropagation = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - - const isModalOpened = useAtomComponentStateValue( - isModalOpenedComponentState, - modalId, - ); - - const { closeModal } = useModal(); - - const handleClose = () => { - onClose?.(); - if (shouldCloseModalOnClickOutsideOrEscape) closeModal(modalId); - }; - - const modalContent = ( - - {isModalOpened && ( - - - - - - {children} - - - - - )} - - ); - - if (isDefined(effectiveContainer)) { - return createPortal(modalContent, effectiveContainer); - } - - return modalContent; -}; - -Modal.Header = ModalHeader; -Modal.Content = ModalContent; -Modal.Footer = ModalFooter; -Modal.Backdrop = StyledBackDrop; diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx index 76f5b548aa..7a4f8227fa 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx @@ -9,7 +9,7 @@ type ModalHotkeysAndClickOutsideEffectProps = { onEnter?: () => void; isClosable?: boolean; onClose?: () => void; - modalId: string; + modalInstanceId: string; }; export const ModalHotkeysAndClickOutsideEffect = ({ @@ -17,14 +17,14 @@ export const ModalHotkeysAndClickOutsideEffect = ({ onEnter, isClosable = false, onClose, - modalId, + modalInstanceId, }: ModalHotkeysAndClickOutsideEffectProps) => { useHotkeysOnFocusedElement({ keys: [Key.Enter], callback: () => { onEnter?.(); }, - focusId: modalId, + focusId: modalInstanceId, dependencies: [onEnter], }); @@ -35,7 +35,7 @@ export const ModalHotkeysAndClickOutsideEffect = ({ onClose(); } }, - focusId: modalId, + focusId: modalInstanceId, dependencies: [isClosable, onClose], }); @@ -45,7 +45,7 @@ export const ModalHotkeysAndClickOutsideEffect = ({ MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID, DIALOG_CLICK_OUTSIDE_ID, ], - listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`, + listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalInstanceId}`, callback: () => { if (isClosable && onClose !== undefined) { onClose(); diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/ModalStatefulWrapper.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/ModalStatefulWrapper.tsx new file mode 100644 index 0000000000..a439d5c6d9 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/ModalStatefulWrapper.tsx @@ -0,0 +1,96 @@ +import { ModalHotkeysAndClickOutsideEffect } from '@/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect'; +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 { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; +import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext'; +import { useModalContainer } from '@/ui/layout/modal/contexts/ModalContainerContext'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; +import { type ModalStatefulWrapperProps } from '@/ui/layout/modal/types/ModalStatefulWrapperProps'; +import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useRef } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { Modal } from 'twenty-ui/layout'; + +export const ModalStatefulWrapper = ({ + modalInstanceId, + children, + size = 'medium', + padding = 'medium', + onEnter, + isClosable = false, + onClose, + overlay = 'dark', + dataGloballyPreventClickOutside = false, + shouldCloseModalOnClickOutsideOrEscape = true, + renderInDocumentBody = false, + gap, + smallBorderRadius, + narrowWidth, + autoHeight, +}: ModalStatefulWrapperProps) => { + const isMobile = useIsMobile(); + const modalRef = useRef(null); + const { container } = useModalContainer(); + + const effectiveContainer = renderInDocumentBody ? document.body : container; + const isInContainer = isDefined(container) && !renderInDocumentBody; + + const isModalOpened = useAtomComponentStateValue( + isModalOpenedComponentState, + modalInstanceId, + ); + + const { closeModal } = useModal(); + + const handleClose = () => { + onClose?.(); + if (shouldCloseModalOnClickOutsideOrEscape) { + closeModal(modalInstanceId); + } + }; + + return ( + + + {isModalOpened && ( + + )} + + {children} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx index 7e9476aeef..693c005012 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx @@ -54,7 +54,7 @@ const confirmMock = fn(); export const Default: Story = { args: { - modalId: 'confirmation-modal', + modalInstanceId: 'confirmation-modal', title: 'Pariatur labore.', subtitle: 'Velit dolore aliquip laborum occaecat fugiat.', confirmButtonText: 'Delete', @@ -72,7 +72,7 @@ export const InputConfirmation: Story = { export const CloseOnEscape: Story = { args: { - modalId: 'confirmation-modal', + modalInstanceId: 'confirmation-modal', title: 'Escape Key Test', subtitle: 'This modal should close when pressing the Escape key.', confirmButtonText: 'Confirm', @@ -95,7 +95,7 @@ export const CloseOnEscape: Story = { export const CloseOnClickOutside: Story = { args: { - modalId: 'confirmation-modal', + modalInstanceId: 'confirmation-modal', title: 'Click Outside Test', subtitle: 'This modal should close when clicking outside of it.', confirmButtonText: 'Confirm', @@ -121,7 +121,7 @@ export const CloseOnClickOutside: Story = { export const ConfirmWithEnterKey: Story = { args: { - modalId: 'confirmation-modal', + modalInstanceId: 'confirmation-modal', title: 'Enter Key Test', subtitle: 'This modal should confirm when pressing the Enter key.', confirmButtonText: 'Confirm', @@ -142,7 +142,7 @@ export const ConfirmWithEnterKey: Story = { export const CancelButtonClick: Story = { args: { - modalId: 'confirmation-modal', + modalInstanceId: 'confirmation-modal', title: 'Cancel Button Test', subtitle: 'Clicking the cancel button should close the modal', confirmButtonText: 'Confirm', @@ -166,7 +166,7 @@ export const CancelButtonClick: Story = { export const ConfirmButtonClick: Story = { args: { - modalId: 'confirmation-modal', + modalInstanceId: 'confirmation-modal', title: 'Confirm Button Test', subtitle: 'Clicking the confirm button should trigger the confirm action', confirmButtonText: 'Confirm', diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx index 6257e3876a..5cee006074 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx @@ -5,11 +5,12 @@ import { } from '@storybook/react-vite'; import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper'; import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; +import { ModalContent, ModalFooter, ModalHeader } from 'twenty-ui/layout'; import { ComponentDecorator } from 'twenty-ui/testing'; import { RootDecorator } from '~/testing/decorators/RootDecorator'; import { sleep } from '~/utils/sleep'; @@ -37,9 +38,9 @@ const JotaiInitDecorator: Decorator = (Story) => { return ; }; -const meta: Meta = { - title: 'UI/Layout/Modal/Modal', - component: Modal, +const meta: Meta = { + title: 'UI/Layout/Modal/ModalStatefulWrapper', + component: ModalStatefulWrapper, decorators: [JotaiInitDecorator, RootDecorator, ComponentDecorator], parameters: { disableHotkeyInitialization: true, @@ -47,26 +48,26 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const closeMock = fn(); export const Default: Story = { args: { - modalId: 'modal-id', + modalInstanceId: 'modal-id', size: 'medium', padding: 'medium', children: ( <> - Stay in touch - + Stay in touch + This is a dummy newletter form so don't bother trying to test it. Not that I expect you to, anyways. :) - - + + By using Twenty, you're opting for the finest CRM experience you'll ever encounter. - + ), }, @@ -74,17 +75,17 @@ export const Default: Story = { export const CloseClosableModalOnClickOutside: Story = { args: { - modalId: 'modal-id', + modalInstanceId: 'modal-id', size: 'medium', padding: 'medium', isClosable: true, onClose: closeMock, children: ( <> - Click Outside Test - + Click Outside Test + This modal should close when clicking outside of it. - + ), }, @@ -94,7 +95,6 @@ export const CloseClosableModalOnClickOutside: Story = { await canvas.findByText('Click Outside Test'); const backdrop = await canvas.findByTestId('modal-backdrop'); - // We need to wait for the outside click listener to be registered await sleep(100); await userEvent.click(backdrop); @@ -106,17 +106,17 @@ export const CloseClosableModalOnClickOutside: Story = { export const CloseClosableModalOnEscape: Story = { args: { - modalId: 'modal-id', + modalInstanceId: 'modal-id', size: 'medium', padding: 'medium', isClosable: true, onClose: closeMock, children: ( <> - Escape Key Test - + Escape Key Test + This modal should close when pressing the Escape key. - + ), }, diff --git a/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx b/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx index c53dc77f20..e5cb023e6e 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx @@ -14,9 +14,11 @@ export const useModal = () => { const store = useStore(); const closeModal = useCallback( - (modalId: string) => { + (modalInstanceId: string) => { const isModalOpen = store.get( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), + isModalOpenedComponentState.atomFamily({ + instanceId: modalInstanceId, + }), ); if (!isModalOpen) { @@ -24,11 +26,13 @@ export const useModal = () => { } removeFocusItemFromFocusStackById({ - focusId: modalId, + focusId: modalInstanceId, }); store.set( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), + isModalOpenedComponentState.atomFamily({ + instanceId: modalInstanceId, + }), false, ); }, @@ -36,9 +40,11 @@ export const useModal = () => { ); const openModal = useCallback( - (modalId: string) => { + (modalInstanceId: string) => { const isModalOpened = store.get( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), + isModalOpenedComponentState.atomFamily({ + instanceId: modalInstanceId, + }), ); if (isModalOpened) { @@ -46,15 +52,17 @@ export const useModal = () => { } store.set( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), + isModalOpenedComponentState.atomFamily({ + instanceId: modalInstanceId, + }), true, ); pushFocusItemToFocusStack({ - focusId: modalId, + focusId: modalInstanceId, component: { type: FocusComponentType.MODAL, - instanceId: modalId, + instanceId: modalInstanceId, }, globalHotkeysConfig: { enableGlobalHotkeysWithModifiers: false, @@ -66,15 +74,17 @@ export const useModal = () => { ); const toggleModal = useCallback( - (modalId: string) => { + (modalInstanceId: string) => { const isModalOpen = store.get( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), + isModalOpenedComponentState.atomFamily({ + instanceId: modalInstanceId, + }), ); if (isModalOpen) { - closeModal(modalId); + closeModal(modalInstanceId); } else { - openModal(modalId); + openModal(modalInstanceId); } }, [store, closeModal, openModal], diff --git a/packages/twenty-front/src/modules/ui/layout/modal/types/ModalStatefulWrapperProps.ts b/packages/twenty-front/src/modules/ui/layout/modal/types/ModalStatefulWrapperProps.ts new file mode 100644 index 0000000000..ddbc98fc2d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/modal/types/ModalStatefulWrapperProps.ts @@ -0,0 +1,23 @@ +import type React from 'react'; +import { type ModalProps } from 'twenty-ui/layout'; + +export type ModalStatefulWrapperProps = Pick< + ModalProps, + | 'size' + | 'padding' + | 'overlay' + | 'gap' + | 'smallBorderRadius' + | 'narrowWidth' + | 'autoHeight' +> & + React.PropsWithChildren & { + modalInstanceId: string; + onEnter?: () => void; + dataGloballyPreventClickOutside?: boolean; + shouldCloseModalOnClickOutsideOrEscape?: boolean; + renderInDocumentBody?: boolean; + } & ( + | { isClosable: true; onClose?: () => void } + | { isClosable?: false; onClose?: never } + ); diff --git a/packages/twenty-front/src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx b/packages/twenty-front/src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx index f342eccb26..2ccd9b5c9b 100644 --- a/packages/twenty-front/src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx @@ -42,7 +42,7 @@ export const OverrideWorkflowDraftConfirmationModal = ({ return ( <> { return ( isTokenValid && ( - + { )} - + ) ); }; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index b34e029d24..f76b5c492a 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -29,7 +29,7 @@ import { SignInUpTwoFactorAuthenticationProvision } from '@/auth/sign-in-up/comp import { SignInUpTOTPVerification } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification'; import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useLingui } from '@lingui/react/macro'; import { useSearchParams } from 'react-router-dom'; import { isDefined } from 'twenty-shared/utils'; @@ -61,7 +61,7 @@ const StandardContent = ({ onClickOnLogo: () => void; }) => { return ( - + } - + ); }; @@ -205,9 +205,9 @@ export const SignInUp = () => { if (signInUpStep === SignInUpStep.EmailVerification) { return ( - + - + ); } diff --git a/packages/twenty-front/src/pages/onboarding/BookCall.tsx b/packages/twenty-front/src/pages/onboarding/BookCall.tsx index 14961ff6df..4eb043cc61 100644 --- a/packages/twenty-front/src/pages/onboarding/BookCall.tsx +++ b/packages/twenty-front/src/pages/onboarding/BookCall.tsx @@ -1,12 +1,11 @@ import Cal from '@calcom/embed-react'; -import { styled } from '@linaria/react'; import { useContext } from 'react'; import { Link } from 'react-router-dom'; import { currentUserState } from '@/auth/states/currentUserState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent, ModalFooter } from 'twenty-ui/layout'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useLingui } from '@lingui/react/macro'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; @@ -14,24 +13,12 @@ import { AppPath } from 'twenty-shared/types'; import { IconChevronLeft, IconChevronRightPipe } from 'twenty-ui/display'; import { LightButton } from 'twenty-ui/input'; import { ThemeContext } from 'twenty-ui/theme'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; import { useIsMobile } from 'twenty-ui/utilities'; import { OnboardingStatus, useSkipBookOnboardingStepMutation, } from '~/generated-metadata/graphql'; -const StyledModalFooter = styled(Modal.Footer)` - height: auto; - justify-content: center; - padding: ${themeCssVariables.spacing[3]}; -`; - -const StyledModalContent = styled(Modal.Content)` - overflow: hidden; - padding: 0; -`; - export const BookCall = () => { const { t } = useLingui(); const { theme } = useContext(ThemeContext); @@ -51,7 +38,12 @@ export const BookCall = () => { return ( <> - + { }} /> - - + + {isPlanRequired ? ( @@ -79,7 +71,7 @@ export const BookCall = () => { onClick={handleCompleteOnboarding} /> )} - + ); }; diff --git a/packages/twenty-front/src/pages/onboarding/BookCallDecision.tsx b/packages/twenty-front/src/pages/onboarding/BookCallDecision.tsx index bb614926b6..9d7e17c2d8 100644 --- a/packages/twenty-front/src/pages/onboarding/BookCallDecision.tsx +++ b/packages/twenty-front/src/pages/onboarding/BookCallDecision.tsx @@ -1,7 +1,7 @@ import { SubTitle } from '@/auth/components/SubTitle'; import { Title } from '@/auth/components/Title'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { styled } from '@linaria/react'; import { Trans, useLingui } from '@lingui/react/macro'; import { Link } from 'react-router-dom'; @@ -17,10 +17,6 @@ const StyledCoverImage = styled.img` width: 320px; `; -const StyledModalContent = styled(Modal.Content)` - gap: ${themeCssVariables.spacing[8]}; -`; - const StyledTitleContainer = styled.div` align-items: center; display: flex; @@ -51,7 +47,7 @@ export const BookCallDecision = () => { }; return ( - + <Trans>Book your onboarding</Trans> @@ -70,6 +66,6 @@ export const BookCallDecision = () => { </StyledLink> <LightButton title={t`Finish`} onClick={handleFinish} /> </StyledButtonContainer> - </StyledModalContent> + </ModalContent> ); }; diff --git a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx index e71dcab80f..768c4b5842 100644 --- a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx +++ b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx @@ -1,4 +1,4 @@ -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { styled } from '@linaria/react'; import { isDefined } from 'twenty-shared/utils'; import { ChooseYourPlanContent } from '~/pages/onboarding/internal/ChooseYourPlanContent'; @@ -14,12 +14,12 @@ export const ChooseYourPlan = () => { const { isPlansLoaded } = usePlans(); const billing = useAtomStateValue(billingState); return ( - <Modal.Content isVerticalCentered> + <ModalContent isVerticallyCentered> {isDefined(billing) && isPlansLoaded ? ( <ChooseYourPlanContent billing={billing} /> ) : ( <StyledChooseYourPlanPlaceholder /> )} - </Modal.Content> + </ModalContent> ); }; diff --git a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx index 5885f5037d..48fb791f96 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx @@ -18,7 +18,7 @@ import { WorkspaceMemberPictureUploader } from '@/settings/workspace-member/comp import { PageFocusId } from '@/types/PageFocusId'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInput } from '@/ui/input/components/TextInput'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { ApolloError } from '@apollo/client'; import { i18n } from '@lingui/core'; @@ -172,7 +172,7 @@ export const CreateProfile = () => { }); return ( - <Modal.Content isVerticalCentered isHorizontalCentered> + <ModalContent isVerticallyCentered isHorizontallyCentered> <Title noMarginTop> <Trans>Create profile</Trans> @@ -251,6 +251,6 @@ export const CreateProfile = () => { fullWidth /> - + ); }; diff --git a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx index 4e9c688f3f..df5c47465a 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx @@ -14,7 +14,7 @@ import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboard import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInput } from '@/ui/input/components/TextInput'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; import { ApolloError } from '@apollo/client'; import { Trans, useLingui } from '@lingui/react/macro'; @@ -152,7 +152,7 @@ export const CreateWorkspace = () => { }; return ( - + {pendingCreationLoaderStep !== PendingCreationLoaderStep.None && ( <> { )} - + ); }; diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx index 3a22476cd3..e49664fceb 100644 --- a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx +++ b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx @@ -6,7 +6,7 @@ import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboard import { PageFocusId } from '@/types/PageFocusId'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInput } from '@/ui/input/components/TextInput'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { styled } from '@linaria/react'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -169,7 +169,7 @@ export const InviteTeam = () => { }); return ( - + <Trans>Invite your team</Trans> @@ -229,6 +229,6 @@ export const InviteTeam = () => { Skip - + ); }; diff --git a/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx b/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx index 62c5dc538d..5e6432190b 100644 --- a/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx +++ b/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx @@ -2,7 +2,7 @@ import { SubTitle } from '@/auth/components/SubTitle'; import { Title } from '@/auth/components/Title'; import { currentUserState } from '@/auth/states/currentUserState'; import { OnboardingModalCircularIcon } from '@/onboarding/components/OnboardingModalCircularIcon'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; @@ -13,15 +13,10 @@ import { isDefined } from 'twenty-shared/utils'; import { IconCheck } from 'twenty-ui/display'; import { Loader } from 'twenty-ui/feedback'; import { MainButton } from 'twenty-ui/input'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; import { AnimatedEaseIn } from 'twenty-ui/utilities'; import { useGetCurrentUserLazyQuery } from '~/generated-metadata/graphql'; import { useNavigateApp } from '~/hooks/useNavigateApp'; -const StyledModalContent = styled(Modal.Content)` - gap: ${themeCssVariables.spacing[8]}; -`; - const StyledTitleContainer = styled.div` display: flex; flex-direction: column; @@ -67,7 +62,7 @@ export const PaymentSuccess = () => { }; return ( - + @@ -82,6 +77,6 @@ export const PaymentSuccess = () => { Icon={() => (isLoading ? : null)} disabled={isLoading} /> - + ); }; diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx index 183f869f4b..07ce8713c7 100644 --- a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx +++ b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx @@ -15,7 +15,7 @@ import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicros import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState'; import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; import { PageFocusId } from '@/types/PageFocusId'; -import { Modal } from '@/ui/layout/modal/components/Modal'; +import { ModalContent } from 'twenty-ui/layout'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { t } from '@lingui/core/macro'; import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types'; @@ -116,7 +116,7 @@ export const SyncEmails = () => { }); return ( - + {t`Emails and Calendar`} {t`Sync your Emails and Calendar with Twenty. Choose your privacy settings.`} @@ -175,6 +175,6 @@ export const SyncEmails = () => { {t`Continue without sync`} - + ); }; diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminConfigVariableDetails.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminConfigVariableDetails.tsx index d1c06a55d5..af037b5768 100644 --- a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminConfigVariableDetails.tsx +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminConfigVariableDetails.tsx @@ -195,7 +195,7 @@ export const SettingsAdminConfigVariableDetails = () => { { diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentEvalsTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentEvalsTab.tsx index 9188fa65c4..f45f6ede40 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentEvalsTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAgentEvalsTab.tsx @@ -198,7 +198,7 @@ export const SettingsAgentEvalsTab = ({ { @@ -609,7 +609,7 @@ export const SettingsApplicationRegistrationDetails = () => { diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx index 92f09658a0..914e0d67de 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx @@ -110,7 +110,7 @@ export const SettingsApplicationDetailAboutTab = ({ diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index bae2a6ed88..27b13d5d9f 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -429,7 +429,7 @@ export const SettingsObjectFieldEdit = () => { {fieldMetadataItem?.isCustom && ( { @@ -340,7 +340,7 @@ export const SettingsDevelopersApiKeyDetail = () => { diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx index bdd6e037c4..d45bc405f2 100644 --- a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx @@ -237,7 +237,7 @@ export const SettingsDomain = () => { { diff --git a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx index fc7356b0f5..2589f91e46 100644 --- a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx +++ b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx @@ -219,7 +219,7 @@ export const SettingsWorkspaceMember = () => { [0]; export default defineConfig(({ command, mode }) => { @@ -107,9 +109,9 @@ export default defineConfig(({ command, mode }) => { configPath: path.resolve(__dirname, './lingui.config.ts'), }), checker(checkers), - { - ...wyw({ - include: ['**/*.{ts,tsx}'], + createWywProfilingPlugin( + wyw({ + include: [path.resolve(__dirname, 'src') + '/**/*.{ts,tsx}'], exclude: [ '**/generated-metadata/**', '**/testing/mock-data/generated/**', @@ -135,8 +137,7 @@ export default defineConfig(({ command, mode }) => { plugins: ['@babel/plugin-transform-export-namespace-from'], }, }), - enforce: 'pre', - }, + ), visualizer({ open: true, gzipSize: true, diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index 469350840d..d1eea9c69f 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -95,6 +95,11 @@ "import": "./dist/utils.mjs", "require": "./dist/utils.cjs" }, + "./vite": { + "types": "./dist/vite/index.d.ts", + "import": "./dist/vite.mjs", + "require": "./dist/vite.cjs" + }, "./workflow": { "types": "./dist/workflow/index.d.ts", "import": "./dist/workflow.mjs", @@ -118,6 +123,7 @@ "translations", "types", "utils", + "vite", "workflow", "workspace" ], @@ -153,6 +159,9 @@ "utils": [ "dist/utils/index.d.ts" ], + "vite": [ + "dist/vite/index.d.ts" + ], "workflow": [ "dist/workflow/index.d.ts" ], diff --git a/packages/twenty-shared/project.json b/packages/twenty-shared/project.json index f4d41461d3..8f13e07a10 100644 --- a/packages/twenty-shared/project.json +++ b/packages/twenty-shared/project.json @@ -40,6 +40,8 @@ "{projectRoot}/types/dist", "{projectRoot}/utils/package.json", "{projectRoot}/utils/dist", + "{projectRoot}/vite/package.json", + "{projectRoot}/vite/dist", "{projectRoot}/workflow/package.json", "{projectRoot}/workflow/dist", "{projectRoot}/workspace/package.json", diff --git a/packages/twenty-shared/src/vite/createWywProfilingPlugin.ts b/packages/twenty-shared/src/vite/createWywProfilingPlugin.ts new file mode 100644 index 0000000000..cb184a4374 --- /dev/null +++ b/packages/twenty-shared/src/vite/createWywProfilingPlugin.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-console */ +import { type Plugin } from 'vite'; + +const LINARIA_IMPORT_RE = /@linaria/; + +type WywProfilingOptions = { + slowThresholdMs?: number; + topSlowFilesCount?: number; + progressIntervalFiles?: number; +}; + +export const createWywProfilingPlugin = ( + wywPlugin: Plugin, + options?: WywProfilingOptions, +): Plugin => { + const slowThresholdMs = options?.slowThresholdMs ?? 50; + const topSlowFilesCount = options?.topSlowFilesCount ?? 10; + const progressIntervalFiles = options?.progressIntervalFiles ?? 50; + + let totalMs = 0; + let fileCount = 0; + let skippedCount = 0; + const slowFiles: { id: string; ms: number }[] = []; + const originalTransform = wywPlugin.transform; + + console.log( + `[linaria/wyw] CSS pre-build profiling enabled (slow threshold: ${slowThresholdMs}ms)`, + ); + + return { + ...wywPlugin, + enforce: 'pre' as const, + transform(code: string, id: string, ...rest: unknown[]) { + if (!LINARIA_IMPORT_RE.test(code)) { + skippedCount++; + return null; + } + + const start = performance.now(); + const result = (originalTransform as Function).call( + this, + code, + id, + ...rest, + ); + + const handleTiming = (elapsed: number) => { + totalMs += elapsed; + fileCount++; + + if (elapsed > slowThresholdMs) { + slowFiles.push({ id, ms: elapsed }); + } + + if (fileCount % progressIntervalFiles === 0) { + console.log( + `[linaria/wyw] CSS pre-build progress: ${fileCount} transformed, ${skippedCount} skipped, ${totalMs.toFixed(0)}ms total`, + ); + } + }; + + if (result && typeof result === 'object' && 'then' in result) { + return (result as Promise).then((res) => { + handleTiming(performance.now() - start); + return res; + }); + } + + handleTiming(performance.now() - start); + return result; + }, + buildEnd() { + console.log('\n[linaria/wyw] ===== CSS PRE-BUILD TIMING SUMMARY ====='); + console.log(`[linaria/wyw] Files transformed: ${fileCount}`); + console.log(`[linaria/wyw] Files skipped (no @linaria): ${skippedCount}`); + console.log(`[linaria/wyw] Transform time: ${totalMs.toFixed(0)}ms`); + console.log( + `[linaria/wyw] Avg per transformed file: ${fileCount > 0 ? (totalMs / fileCount).toFixed(1) : 0}ms`, + ); + + if (slowFiles.length > 0) { + console.log( + `[linaria/wyw] Slow CSS pre-build files (>${slowThresholdMs}ms):`, + ); + slowFiles + .sort((a, b) => b.ms - a.ms) + .slice(0, topSlowFilesCount) + .forEach((slowFile) => + console.log( + `[linaria/wyw] ${slowFile.ms.toFixed(0)}ms ${slowFile.id.replace(process.cwd(), '')}`, + ), + ); + } + + console.log('[linaria/wyw] ==========================================\n'); + }, + }; +}; diff --git a/packages/twenty-shared/src/vite/index.ts b/packages/twenty-shared/src/vite/index.ts new file mode 100644 index 0000000000..06d82f00c5 --- /dev/null +++ b/packages/twenty-shared/src/vite/index.ts @@ -0,0 +1,10 @@ +/* + * _____ _ + *|_ _|_ _____ _ __ | |_ _ _ + * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file + * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden + * |_| \_/\_/ \___|_| |_|\__|\__, | + * |___/ + */ + +export { createWywProfilingPlugin } from './createWywProfilingPlugin'; diff --git a/packages/twenty-ui/eslint.config.mjs b/packages/twenty-ui/eslint.config.mjs index 4820d47f2f..02927e0d87 100644 --- a/packages/twenty-ui/eslint.config.mjs +++ b/packages/twenty-ui/eslint.config.mjs @@ -14,6 +14,7 @@ export default [ { ignores: [ '**/node_modules/**', + '**/generated/**', ], }, diff --git a/packages/twenty-ui/src/components/avatar-or-icon/AvatarOrIcon.tsx b/packages/twenty-ui/src/components/avatar-or-icon/AvatarOrIcon.tsx index 24de8c13d8..b12baff556 100644 --- a/packages/twenty-ui/src/components/avatar-or-icon/AvatarOrIcon.tsx +++ b/packages/twenty-ui/src/components/avatar-or-icon/AvatarOrIcon.tsx @@ -67,10 +67,7 @@ export const AvatarOrIcon = ({ if (isIconInverted || isDefined(IconBackgroundColor)) { return ( - + + diff --git a/packages/twenty-ui/src/individual-entry.ts b/packages/twenty-ui/src/individual-entry.ts index d762a51893..11fa297fe9 100644 --- a/packages/twenty-ui/src/individual-entry.ts +++ b/packages/twenty-ui/src/individual-entry.ts @@ -13,4 +13,3 @@ export * from './layout'; export * from './navigation'; export * from './theme'; export * from './utilities'; - diff --git a/packages/twenty-ui/src/json-visualizer/components/JsonNestedNode.tsx b/packages/twenty-ui/src/json-visualizer/components/JsonNestedNode.tsx index a29ff56806..5fe3df3d4c 100644 --- a/packages/twenty-ui/src/json-visualizer/components/JsonNestedNode.tsx +++ b/packages/twenty-ui/src/json-visualizer/components/JsonNestedNode.tsx @@ -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'}; } `; diff --git a/packages/twenty-ui/src/layout/index.ts b/packages/twenty-ui/src/layout/index.ts index 29939a08bf..9531b3115c 100644 --- a/packages/twenty-ui/src/layout/index.ts +++ b/packages/twenty-ui/src/layout/index.ts @@ -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, diff --git a/packages/twenty-ui/src/layout/modal/components/Modal.tsx b/packages/twenty-ui/src/layout/modal/components/Modal.tsx new file mode 100644 index 0000000000..bd7d71e9e8 --- /dev/null +++ b/packages/twenty-ui/src/layout/modal/components/Modal.tsx @@ -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(null); + const resolvedRef = externalRef ?? internalRef; + const { theme } = useContext(ThemeContext); + + const handleBackdropMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + onBackdropMouseDown?.(e); + }; + + const content = ( + + {isOpen && ( + + + {children} + + + )} + + ); + + if (isDefined(container)) { + return createPortal(content, container); + } + + return content; +}; diff --git a/packages/twenty-ui/src/layout/modal/components/ModalBackdrop.tsx b/packages/twenty-ui/src/layout/modal/components/ModalBackdrop.tsx new file mode 100644 index 0000000000..8dadbe4b04 --- /dev/null +++ b/packages/twenty-ui/src/layout/modal/components/ModalBackdrop.tsx @@ -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; diff --git a/packages/twenty-ui/src/layout/modal/components/ModalContent.tsx b/packages/twenty-ui/src/layout/modal/components/ModalContent.tsx new file mode 100644 index 0000000000..7276349ea7 --- /dev/null +++ b/packages/twenty-ui/src/layout/modal/components/ModalContent.tsx @@ -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) => ( + + {children} + +); diff --git a/packages/twenty-ui/src/layout/modal/components/ModalFooter.tsx b/packages/twenty-ui/src/layout/modal/components/ModalFooter.tsx new file mode 100644 index 0000000000..737a52f017 --- /dev/null +++ b/packages/twenty-ui/src/layout/modal/components/ModalFooter.tsx @@ -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) => ( + + {children} + +); diff --git a/packages/twenty-ui/src/layout/modal/components/ModalHeader.tsx b/packages/twenty-ui/src/layout/modal/components/ModalHeader.tsx new file mode 100644 index 0000000000..82d9671098 --- /dev/null +++ b/packages/twenty-ui/src/layout/modal/components/ModalHeader.tsx @@ -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) => ( + + {children} + +); diff --git a/packages/twenty-ui/src/layout/modal/components/__stories__/Modal.stories.tsx b/packages/twenty-ui/src/layout/modal/components/__stories__/Modal.stories.tsx new file mode 100644 index 0000000000..995d144f43 --- /dev/null +++ b/packages/twenty-ui/src/layout/modal/components/__stories__/Modal.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + args: { + isOpen: true, + size: 'medium', + padding: 'none', + overlay: 'dark', + }, + render: ({ isOpen, size, padding, overlay }) => ( + + + + + +
+ Workspace name and subdomain can be changed from the settings panel. + These changes will be reflected across all members. +
+
+ +