Refactor modal (#18377)

## Summary

- Move Modal UI components (`Modal`, `ModalContent`, `ModalHeader`,
`ModalFooter`, `ModalBackdrop`) from `twenty-front` to `twenty-ui` as
stateless, reusable components
- Create `ModalStatefulWrapper` in `twenty-front` that connects Jotai
state (`isModalOpenedComponentState`) to the stateless `Modal` via an
`isOpen` prop
- Rename `modalVariant` prop to `overlay` with clearer values: `'dark'`
(default), `'light'` (in-container), `'transparent'` (invisible panel).
Remove unused `'medium'` overlay
- Rename `modalId` to `modalInstanceId` across the entire modal zone
(~30 consumer files)
- Extract `ModalProps` to its own file in
`twenty-ui/types/ModalProps.ts`; extract `ModalStatefulWrapperProps` to
its own file using `Pick<ModalProps, ...>` for shared props
- Extract `ModalBackdrop` to its own file and export from `twenty-ui`;
use it in `UserOrMetadataLoader` instead of a local styled component
- Use `ModalFooter` in `StepNavigationButton` and `ModalHeader` in
`SpreadsheetImportStepperContainer` instead of duplicated `styled.div`
definitions
- Remove unused `onClose` prop from stateless `Modal`; fix `typeof
document` guard in `ModalStatefulWrapper`
- Split shared types into individual files: `ModalSize.ts`,
`ModalPadding.ts`, `ModalOverlay.ts`
- Extract wyw profiling instrumentation from `vite.config.ts` into
reusable `createWywProfilingPlugin` with parametrized threshold and
improved logging
- Delete old `Modal.tsx`, `Modal.styles.ts`, `ModalContent.tsx`,
`ModalHeader.tsx`, `ModalFooter.tsx` from `twenty-front`
- Add comprehensive Storybook stories in `twenty-ui` covering Default,
Confirmation, Small, ExtraLarge, Closed, and Interactive variants
This commit is contained in:
Charles Bochet
2026-03-04 13:22:31 +01:00
committed by GitHub
parent 995793c0ac
commit 3b2bf39565
99 changed files with 4283 additions and 3688 deletions
@@ -1,6 +1,6 @@
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@@ -100,12 +100,12 @@ export const GlobalFilePreviewModal = (): JSX.Element | null => {
return (
<>
{createPortal(
<Modal
modalId={GLOBAL_FILE_PREVIEW_MODAL_ID}
<ModalStatefulWrapper
modalInstanceId={GLOBAL_FILE_PREVIEW_MODAL_ID}
size="large"
isClosable
onClose={handleClose}
ignoreContainer
renderInDocumentBody
>
<StyledModalHeader>
<StyledHeader>
@@ -141,7 +141,7 @@ export const GlobalFilePreviewModal = (): JSX.Element | null => {
</Suspense>
</StyledModalContent>
</ScrollWrapper>
</Modal>,
</ModalStatefulWrapper>,
document.body,
)}
</>
@@ -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 (
<StyledConfirmationModal
modalId={modalId}
<ModalStatefulWrapper
modalInstanceId={modalInstanceId}
onClose={() => {
onClose?.();
}}
onEnter={handleEnter}
isClosable={true}
padding="large"
modalVariant={modalVariant}
overlay={overlay}
dataGloballyPreventClickOutside
ignoreContainer
renderInDocumentBody
smallBorderRadius
narrowWidth
autoHeight
>
<StyledCenteredTitle>
<H1Title title={title} fontColor={H1TitleFontColor.Primary} />
@@ -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"
/>
</StyledConfirmationModal>
</ModalStatefulWrapper>
);
};
@@ -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) => (
<StyledHeader className={className}>{children}</StyledHeader>
);
type ModalContentProps = React.PropsWithChildren & {
className?: string;
isVerticalCentered?: boolean;
isHorizontalCentered?: boolean;
};
const ModalContent = ({
children,
className,
isVerticalCentered,
isHorizontalCentered,
}: ModalContentProps) => (
<StyledContent
className={className}
isVerticalCentered={isVerticalCentered}
isHorizontalCentered={isHorizontalCentered}
>
{children}
</StyledContent>
);
type ModalFooterProps = React.PropsWithChildren & {
className?: string;
};
const ModalFooter = ({ children, className }: ModalFooterProps) => (
<StyledFooter className={className}>{children}</StyledFooter>
);
export type ModalSize = 'small' | 'medium' | 'large' | 'extraLarge';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
export type ModalVariants =
| 'primary'
| 'secondary'
| 'tertiary'
| '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<HTMLDivElement>(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 = (
<AnimatePresence mode="wait">
{isModalOpened && (
<ModalComponentInstanceContext.Provider
value={{
instanceId: modalId,
}}
>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
}}
>
<ModalHotkeysAndClickOutsideEffect
modalId={modalId}
modalRef={modalRef}
onEnter={onEnter}
isClosable={isClosable}
onClose={handleClose}
/>
<StyledBackDrop
data-testid="modal-backdrop"
data-click-outside-id={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
onMouseDown={stopEventPropagation}
modalVariant={modalVariant}
isInContainer={isInContainer}
>
<StyledModalDiv
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
modalVariant={modalVariant}
variants={modalAnimation}
transition={{ duration: theme.animation.duration.normal }}
className={className}
isMobile={isMobile}
data-globally-prevent-click-outside={
dataGloballyPreventClickOutside
}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
</ClickOutsideListenerContext.Provider>
</ModalComponentInstanceContext.Provider>
)}
</AnimatePresence>
);
if (isDefined(effectiveContainer)) {
return createPortal(modalContent, effectiveContainer);
}
return modalContent;
};
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;
Modal.Backdrop = StyledBackDrop;
@@ -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();
@@ -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<HTMLDivElement>(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 (
<ModalComponentInstanceContext.Provider
value={{ instanceId: modalInstanceId }}
>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
}}
>
{isModalOpened && (
<ModalHotkeysAndClickOutsideEffect
modalInstanceId={modalInstanceId}
modalRef={modalRef}
onEnter={onEnter}
isClosable={isClosable}
onClose={handleClose}
/>
)}
<Modal
isOpen={isModalOpened}
size={size}
padding={padding}
overlay={isInContainer ? 'light' : overlay}
isMobile={isMobile}
isInContainer={isInContainer}
container={effectiveContainer}
gap={gap}
smallBorderRadius={smallBorderRadius}
narrowWidth={narrowWidth}
autoHeight={autoHeight}
modalZIndex={RootStackingContextZIndices.RootModal}
backdropZIndex={RootStackingContextZIndices.RootModalBackDrop}
backdropClickOutsideId={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
preventClickOutside={dataGloballyPreventClickOutside}
modalRef={modalRef}
>
{children}
</Modal>
</ClickOutsideListenerContext.Provider>
</ModalComponentInstanceContext.Provider>
);
};
@@ -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',
@@ -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 <Story />;
};
const meta: Meta<typeof Modal> = {
title: 'UI/Layout/Modal/Modal',
component: Modal,
const meta: Meta<typeof ModalStatefulWrapper> = {
title: 'UI/Layout/Modal/ModalStatefulWrapper',
component: ModalStatefulWrapper,
decorators: [JotaiInitDecorator, RootDecorator, ComponentDecorator],
parameters: {
disableHotkeyInitialization: true,
@@ -47,26 +48,26 @@ const meta: Meta<typeof Modal> = {
};
export default meta;
type Story = StoryObj<typeof Modal>;
type Story = StoryObj<typeof ModalStatefulWrapper>;
const closeMock = fn();
export const Default: Story = {
args: {
modalId: 'modal-id',
modalInstanceId: 'modal-id',
size: 'medium',
padding: 'medium',
children: (
<>
<Modal.Header>Stay in touch</Modal.Header>
<Modal.Content>
<ModalHeader>Stay in touch</ModalHeader>
<ModalContent>
This is a dummy newletter form so don't bother trying to test it. Not
that I expect you to, anyways. :)
</Modal.Content>
<Modal.Footer>
</ModalContent>
<ModalFooter>
By using Twenty, you're opting for the finest CRM experience you'll
ever encounter.
</Modal.Footer>
</ModalFooter>
</>
),
},
@@ -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: (
<>
<Modal.Header>Click Outside Test</Modal.Header>
<Modal.Content>
<ModalHeader>Click Outside Test</ModalHeader>
<ModalContent>
This modal should close when clicking outside of it.
</Modal.Content>
</ModalContent>
</>
),
},
@@ -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: (
<>
<Modal.Header>Escape Key Test</Modal.Header>
<Modal.Content>
<ModalHeader>Escape Key Test</ModalHeader>
<ModalContent>
This modal should close when pressing the Escape key.
</Modal.Content>
</ModalContent>
</>
),
},
@@ -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],
@@ -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 }
);