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:
+5
-5
@@ -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,
|
||||
)}
|
||||
</>
|
||||
|
||||
+23
-20
@@ -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;
|
||||
+5
-5
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
-6
@@ -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',
|
||||
|
||||
+20
-20
@@ -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 }
|
||||
);
|
||||
Reference in New Issue
Block a user