diff --git a/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx b/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx index bad7d277cef..44869ca6f7f 100644 --- a/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx +++ b/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; -import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths'; +import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useTheme } from '@emotion/react'; import { ANIMATION } from 'twenty-ui/theme'; @@ -54,9 +54,7 @@ export const LeftPanelSkeletonLoader = () => { ({ key: 'tokenPairState', diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuSidePanel.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuSidePanel.tsx new file mode 100644 index 00000000000..5beb3815e10 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuSidePanel.tsx @@ -0,0 +1,147 @@ +import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter'; +import { CommandMenuWidthEffect } from '@/command-menu/components/CommandMenuWidthEffect'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup'; +import { + COMMAND_MENU_WIDTH_VAR, + commandMenuWidthState, +} from '@/command-menu/states/commandMenuWidthState'; +import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState'; +import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; +import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState'; +import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext'; +import { ResizablePanelGap } from '@/ui/layout/resizable-panel/components/ResizablePanelGap'; +import { COMMAND_MENU_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/CommandMenuConstraints'; +import styled from '@emotion/styled'; +import { useCallback, useState } from 'react'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; + +const StyledSidePanelWrapper = styled.div<{ + isOpen: boolean; + isResizing: boolean; +}>` + flex-shrink: 0; + min-width: 0; + overflow: hidden; + width: ${({ isOpen }) => (isOpen ? `var(${COMMAND_MENU_WIDTH_VAR})` : '0px')}; + transition: ${({ isResizing, theme }) => + isResizing ? 'none' : `width ${theme.animation.duration.normal}s`}; +`; + +const StyledSidePanel = styled.aside` + background: ${({ theme }) => theme.background.primary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + position: relative; + width: 100%; + box-sizing: border-box; +`; + +const StyledModalContainer = styled.div` + height: 100%; + left: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 100%; + z-index: 1; +`; + +const GAP_WIDTH = 8; + +export const CommandMenuSidePanel = () => { + const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState); + const isCommandMenuClosing = useRecoilValue(isCommandMenuClosingState); + const [commandMenuWidth, setCommandMenuWidth] = useRecoilState( + commandMenuWidthState, + ); + const { closeCommandMenu } = useCommandMenu(); + const { commandMenuCloseAnimationCompleteCleanup } = + useCommandMenuCloseAnimationCompleteCleanup(); + + const [modalContainer, setModalContainer] = useState( + null, + ); + const [isResizing, setIsResizing] = useState(false); + const [shouldRenderContent, setShouldRenderContent] = + useState(isCommandMenuOpened); + + const setTableWidthResizeIsActive = useSetRecoilState( + tableWidthResizeIsActiveState, + ); + + const shouldShowContent = isCommandMenuOpened || shouldRenderContent; + + const handleTransitionEnd = () => { + if (isCommandMenuOpened) { + // Open animation completed - ensure content persists for close animation + setShouldRenderContent(true); + } else { + // Close animation completed + setShouldRenderContent(false); + if (isCommandMenuClosing) { + commandMenuCloseAnimationCompleteCleanup(); + } + } + }; + + const handleModalContainerRef = useCallback( + (element: HTMLDivElement | null) => { + setModalContainer(element); + }, + [], + ); + + const handleWidthChange = useCallback( + (width: number) => { + setCommandMenuWidth(width); + setIsResizing(false); + setTableWidthResizeIsActive(true); + }, + [setCommandMenuWidth, setTableWidthResizeIsActive], + ); + + const handleResizeStart = useCallback(() => { + setIsResizing(true); + setTableWidthResizeIsActive(false); + }, [setTableWidthResizeIsActive]); + + const handleCollapse = useCallback(() => { + closeCommandMenu(); + setIsResizing(false); + setTableWidthResizeIsActive(true); + }, [closeCommandMenu, setTableWidthResizeIsActive]); + + return ( + <> + + + + + + + + {shouldShowContent && } + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuWidthEffect.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuWidthEffect.tsx new file mode 100644 index 00000000000..6d2fe046372 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuWidthEffect.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { + COMMAND_MENU_WIDTH_VAR, + commandMenuWidthState, +} from '../states/commandMenuWidthState'; + +export const CommandMenuWidthEffect = () => { + const commandMenuWidth = useRecoilValue(commandMenuWidthState); + + useEffect(() => { + document.documentElement.style.setProperty( + COMMAND_MENU_WIDTH_VAR, + `${commandMenuWidth}px`, + ); + }, [commandMenuWidth]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSidePanelWidth.ts b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSidePanelWidth.ts deleted file mode 100644 index 5e08c69137f..00000000000 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSidePanelWidth.ts +++ /dev/null @@ -1 +0,0 @@ -export const COMMAND_MENU_SIDE_PANEL_WIDTH = 400; diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuWidthState.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuWidthState.ts new file mode 100644 index 00000000000..241aec46bd4 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuWidthState.ts @@ -0,0 +1,12 @@ +import { atom } from 'recoil'; + +import { COMMAND_MENU_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/CommandMenuConstraints'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; + +export const COMMAND_MENU_WIDTH_VAR = '--command-menu-width'; + +export const commandMenuWidthState = atom({ + key: 'commandMenuWidth', + default: COMMAND_MENU_CONSTRAINTS.default, + effects: [localStorageEffect()], +}); diff --git a/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts b/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts index 634f8e6e026..e70ead32a97 100644 --- a/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts +++ b/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts @@ -1,4 +1,4 @@ -import { cookieStorageEffect } from '~/utils/recoil-effects'; +import { cookieStorageEffect } from '~/utils/recoil/cookieStorageEffect'; import { createState } from 'twenty-ui/utilities'; export const lastAuthenticatedWorkspaceDomainState = createState< diff --git a/packages/twenty-front/src/modules/navigation/states/lastVisitedObjectMetadataItemIdState.ts b/packages/twenty-front/src/modules/navigation/states/lastVisitedObjectMetadataItemIdState.ts index 4bd38faa6b6..167154ce043 100644 --- a/packages/twenty-front/src/modules/navigation/states/lastVisitedObjectMetadataItemIdState.ts +++ b/packages/twenty-front/src/modules/navigation/states/lastVisitedObjectMetadataItemIdState.ts @@ -1,4 +1,4 @@ -import { localStorageEffect } from '~/utils/recoil-effects'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; import { createState } from 'twenty-ui/utilities'; export const lastVisitedObjectMetadataItemIdState = createState({ diff --git a/packages/twenty-front/src/modules/navigation/states/lastVisitedViewPerObjectMetadataItemState.ts b/packages/twenty-front/src/modules/navigation/states/lastVisitedViewPerObjectMetadataItemState.ts index cee7f268219..9a7057301f2 100644 --- a/packages/twenty-front/src/modules/navigation/states/lastVisitedViewPerObjectMetadataItemState.ts +++ b/packages/twenty-front/src/modules/navigation/states/lastVisitedViewPerObjectMetadataItemState.ts @@ -1,4 +1,4 @@ -import { localStorageEffect } from '~/utils/recoil-effects'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; import { createState } from 'twenty-ui/utilities'; export const lastVisitedViewPerObjectMetadataItemState = createState theme.background.primary}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-radius: ${({ theme }) => theme.border.radius.md}; - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - position: relative; - width: ${COMMAND_MENU_SIDE_PANEL_WIDTH}px; - box-sizing: border-box; -`; - -const StyledModalContainer = styled.div` - height: 100%; - left: 0; - pointer-events: none; - position: absolute; - top: 0; - width: 100%; - z-index: 1; -`; - export const CommandMenuPageLayout = ({ children, }: CommandMenuPageLayoutProps) => { - const theme = useTheme(); const isMobile = useIsMobile(); - const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState); - const isCommandMenuClosing = useRecoilValue(isCommandMenuClosingState); - const { commandMenuCloseAnimationCompleteCleanup } = - useCommandMenuCloseAnimationCompleteCleanup(); - const [modalContainer, setModalContainer] = useState( - null, - ); - - const setTableWidthResizeIsActive = useSetRecoilState( - tableWidthResizeIsActiveState, - ); - - const [shouldRenderContent, setShouldRenderContent] = - useState(isCommandMenuOpened); - - const shouldShowContent = isCommandMenuOpened || shouldRenderContent; - - const handleAnimationComplete = () => { - if (!isCommandMenuOpened) { - setShouldRenderContent(false); - } - - if (isCommandMenuClosing) { - commandMenuCloseAnimationCompleteCleanup(); - } - - setTableWidthResizeIsActive(true); - }; - - const handleAnimationStart = () => { - if (isCommandMenuOpened && !shouldRenderContent) { - setShouldRenderContent(true); - } - - setTableWidthResizeIsActive(false); - }; - - const handleModalContainerRef = useCallback( - (element: HTMLDivElement | null) => { - setModalContainer(element); - }, - [], - ); useCommandMenuHotKeys(); @@ -120,31 +39,7 @@ export const CommandMenuPageLayout = ({ return ( {children} - - - - - - {shouldShowContent && } - - - + ); }; diff --git a/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts b/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts index 7debd8fbd74..cb902faa784 100644 --- a/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts +++ b/packages/twenty-front/src/modules/settings/playground/states/playgroundApiKeyState.ts @@ -1,5 +1,5 @@ import { atom } from 'recoil'; -import { localStorageEffect } from '~/utils/recoil-effects'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; export const playgroundApiKeyState = atom({ key: 'playgroundApiKeyState', diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index f7aef4c3c33..0e4c98aa7dc 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -15,7 +15,7 @@ import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/compone import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage'; import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; -import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths'; +import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { Global, css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -87,7 +87,7 @@ export const DefaultLayout = () => { isSettingsPage && !isMobile && !useShowFullScreen ? (windowsWidth - (OBJECT_SETTINGS_WIDTH + - NAV_DRAWER_WIDTHS.menu.desktop.expanded + + NAVIGATION_DRAWER_CONSTRAINTS.default + 76)) / 2 : 0, diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/components/ResizablePanelEdge.tsx b/packages/twenty-front/src/modules/ui/layout/resizable-panel/components/ResizablePanelEdge.tsx new file mode 100644 index 00000000000..f12f77e5306 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/components/ResizablePanelEdge.tsx @@ -0,0 +1,96 @@ +import styled from '@emotion/styled'; + +import { RESIZE_EDGE_WIDTH_PX } from '../constants/ResizeEdgeWidthPx'; +import { useResizablePanel } from '../hooks/useResizablePanel'; +import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints'; +import { type ResizablePanelSide } from '../types/ResizablePanelSide'; + +type StyledEdgeProps = { + isActive: boolean; + isHovered: boolean; + side: ResizablePanelSide; +}; + +const StyledEdge = styled.div` + position: absolute; + top: 0; + bottom: 0; + ${({ side }) => + side === 'right' ? 'right' : 'left'}: -${RESIZE_EDGE_WIDTH_PX / 2}px; + width: ${RESIZE_EDGE_WIDTH_PX}px; + cursor: col-resize; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +`; + +const StyledHandle = styled.div<{ isActive: boolean; isHovered: boolean }>` + width: 4px; + height: 48px; + border-radius: ${({ theme }) => theme.border.radius.pill}; + background-color: ${({ theme, isActive, isHovered }) => + isActive + ? theme.color.blue + : isHovered + ? theme.font.color.tertiary + : theme.background.quaternary}; + transition: + background-color ${({ theme }) => theme.animation.duration.fast}s, + transform ${({ theme }) => theme.animation.duration.fast}s; + transform: ${({ isHovered, isActive }) => + isHovered || isActive ? 'scaleY(1.2)' : 'scaleY(1)'}; +`; + +type ResizablePanelEdgeProps = { + side: ResizablePanelSide; + constraints: ResizablePanelConstraints; + currentWidth: number; + onWidthChange: (width: number) => void; + onCollapse: () => void; + showHandle?: boolean; + cssVariableName?: string; + onResizeStart?: () => void; +}; + +export const ResizablePanelEdge = ({ + side, + constraints, + currentWidth, + onWidthChange, + onCollapse, + showHandle = true, + cssVariableName, + onResizeStart, +}: ResizablePanelEdgeProps) => { + const { + isHovered, + isResizing, + handleMouseDown, + handleMouseEnter, + handleMouseLeave, + } = useResizablePanel({ + side, + constraints, + currentWidth, + onWidthChange, + onCollapse, + cssVariableName, + onResizeStart, + }); + + return ( + + {showHandle && ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/components/ResizablePanelGap.tsx b/packages/twenty-front/src/modules/ui/layout/resizable-panel/components/ResizablePanelGap.tsx new file mode 100644 index 00000000000..bd3394d5625 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/components/ResizablePanelGap.tsx @@ -0,0 +1,55 @@ +import styled from '@emotion/styled'; + +import { useResizablePanel } from '../hooks/useResizablePanel'; +import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints'; +import { type ResizablePanelSide } from '../types/ResizablePanelSide'; + +const StyledGap = styled.div<{ gapWidth: number }>` + cursor: col-resize; + flex-shrink: 0; + height: 100%; + width: ${({ gapWidth }) => gapWidth}px; + transition: width 0.15s ease; +`; + +type ResizablePanelGapProps = { + side: ResizablePanelSide; + constraints: ResizablePanelConstraints; + currentWidth: number; + onWidthChange: (width: number) => void; + onCollapse: () => void; + gapWidth: number; + cssVariableName?: string; + onResizeStart?: () => void; +}; + +export const ResizablePanelGap = ({ + side, + constraints, + currentWidth, + onWidthChange, + onCollapse, + gapWidth, + cssVariableName, + onResizeStart, +}: ResizablePanelGapProps) => { + const { handleMouseDown, handleMouseEnter, handleMouseLeave } = + useResizablePanel({ + side, + constraints, + currentWidth, + onWidthChange, + onCollapse, + cssVariableName, + onResizeStart, + }); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/CommandMenuConstraints.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/CommandMenuConstraints.ts new file mode 100644 index 00000000000..ad388a161de --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/CommandMenuConstraints.ts @@ -0,0 +1,7 @@ +import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints'; + +export const COMMAND_MENU_CONSTRAINTS: ResizablePanelConstraints = { + min: 320, + max: 600, + default: 400, +}; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/NavigationDrawerCollapsedWidth.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/NavigationDrawerCollapsedWidth.ts new file mode 100644 index 00000000000..59bff5e04ce --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/NavigationDrawerCollapsedWidth.ts @@ -0,0 +1 @@ +export const NAVIGATION_DRAWER_COLLAPSED_WIDTH = 40; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/NavigationDrawerConstraints.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/NavigationDrawerConstraints.ts new file mode 100644 index 00000000000..f6913f3bd75 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/NavigationDrawerConstraints.ts @@ -0,0 +1,7 @@ +import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints'; + +export const NAVIGATION_DRAWER_CONSTRAINTS: ResizablePanelConstraints = { + min: 180, + max: 350, + default: 220, +}; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/ResizeDragThresholdPx.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/ResizeDragThresholdPx.ts new file mode 100644 index 00000000000..7db7dfbf921 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/ResizeDragThresholdPx.ts @@ -0,0 +1 @@ +export const RESIZE_DRAG_THRESHOLD_PX = 5; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/ResizeEdgeWidthPx.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/ResizeEdgeWidthPx.ts new file mode 100644 index 00000000000..acd763050a9 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/constants/ResizeEdgeWidthPx.ts @@ -0,0 +1 @@ +export const RESIZE_EDGE_WIDTH_PX = 8; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/hooks/useResizablePanel.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/hooks/useResizablePanel.ts new file mode 100644 index 00000000000..28b892dbfe8 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/hooks/useResizablePanel.ts @@ -0,0 +1,145 @@ +import { useCallback, useState } from 'react'; + +import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer'; +import { type PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener'; + +import { RESIZE_DRAG_THRESHOLD_PX } from '../constants/ResizeDragThresholdPx'; +import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints'; +import { type ResizablePanelSide } from '../types/ResizablePanelSide'; + +type UseResizablePanelProps = { + side: ResizablePanelSide; + constraints: ResizablePanelConstraints; + currentWidth: number; + onWidthChange: (width: number) => void; + onCollapse: () => void; + cssVariableName?: string; + onResizeStart?: () => void; +}; + +const clampWidth = (width: number, min: number, max: number): number => + Math.min(max, Math.max(min, width)); + +export const useResizablePanel = ({ + side, + constraints, + currentWidth, + onWidthChange, + onCollapse, + cssVariableName, + onResizeStart, +}: UseResizablePanelProps) => { + const [isHovered, setIsHovered] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [startX, setStartX] = useState(null); + const [startWidth, setStartWidth] = useState(0); + const [hasDragged, setHasDragged] = useState(false); + + const handleResizeMove = useCallback( + ({ x }) => { + if (startX === null) return; + + const deltaX = x - startX; + + if (!hasDragged && Math.abs(deltaX) > RESIZE_DRAG_THRESHOLD_PX) { + setHasDragged(true); + onResizeStart?.(); + } + + if (Math.abs(deltaX) > RESIZE_DRAG_THRESHOLD_PX) { + const widthDelta = side === 'right' ? deltaX : -deltaX; + const clampedWidth = clampWidth( + startWidth + widthDelta, + constraints.min, + constraints.max, + ); + + if (cssVariableName !== undefined) { + document.documentElement.style.setProperty( + cssVariableName, + `${clampedWidth}px`, + ); + } + } + }, + [ + startX, + startWidth, + hasDragged, + side, + constraints.min, + constraints.max, + cssVariableName, + onResizeStart, + ], + ); + + const handleResizeEnd = useCallback( + ({ x }) => { + if (startX === null) { + setIsResizing(false); + return; + } + + const deltaX = x - startX; + + if (!hasDragged) { + onCollapse(); + } else { + const widthDelta = side === 'right' ? deltaX : -deltaX; + const finalWidth = clampWidth( + startWidth + widthDelta, + constraints.min, + constraints.max, + ); + onWidthChange(finalWidth); + } + + setStartX(null); + setIsResizing(false); + }, + [ + startX, + startWidth, + hasDragged, + side, + constraints.min, + constraints.max, + onCollapse, + onWidthChange, + ], + ); + + useTrackPointer({ + shouldTrackPointer: isResizing, + onMouseMove: handleResizeMove, + onMouseUp: handleResizeEnd, + }); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + setStartX(event.clientX); + setStartWidth(currentWidth); + setHasDragged(false); + setIsResizing(true); + }, + [currentWidth], + ); + + const handleMouseEnter = useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + }, []); + + return { + isHovered, + isResizing, + handleMouseDown, + handleMouseEnter, + handleMouseLeave, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/types/ResizablePanelConstraints.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/types/ResizablePanelConstraints.ts new file mode 100644 index 00000000000..0ef13e834ec --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/types/ResizablePanelConstraints.ts @@ -0,0 +1,5 @@ +export type ResizablePanelConstraints = { + min: number; + max: number; + default: number; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/resizable-panel/types/ResizablePanelSide.ts b/packages/twenty-front/src/modules/ui/layout/resizable-panel/types/ResizablePanelSide.ts new file mode 100644 index 00000000000..b9123270db8 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/resizable-panel/types/ResizablePanelSide.ts @@ -0,0 +1 @@ +export type ResizablePanelSide = 'left' | 'right'; diff --git a/packages/twenty-front/src/modules/ui/navigation/components/NavigationDrawerWidthEffect.tsx b/packages/twenty-front/src/modules/ui/navigation/components/NavigationDrawerWidthEffect.tsx new file mode 100644 index 00000000000..7f4912b378d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/components/NavigationDrawerWidthEffect.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { + NAVIGATION_DRAWER_WIDTH_VAR, + navigationDrawerWidthState, +} from '../states/navigationDrawerWidthState'; + +export const NavigationDrawerWidthEffect = () => { + const navigationDrawerWidth = useRecoilValue(navigationDrawerWidthState); + + useEffect(() => { + document.documentElement.style.setProperty( + NAVIGATION_DRAWER_WIDTH_VAR, + `${navigationDrawerWidth}px`, + ); + }, [navigationDrawerWidth]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx index 5cf62150486..18f36e737a0 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx @@ -1,15 +1,20 @@ -import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { motion } from 'framer-motion'; -import { type ReactNode, useState } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths'; -import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { type ReactNode, useCallback, useState } from 'react'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer'; +import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState'; +import { ResizablePanelEdge } from '@/ui/layout/resizable-panel/components/ResizablePanelEdge'; +import { NAVIGATION_DRAWER_COLLAPSED_WIDTH } from '@/ui/layout/resizable-panel/constants/NavigationDrawerCollapsedWidth'; +import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; import { isNavigationDrawerExpandedState } from '../../states/isNavigationDrawerExpanded'; +import { + NAVIGATION_DRAWER_WIDTH_VAR, + navigationDrawerWidthState, +} from '../../states/navigationDrawerWidthState'; +import { NavigationDrawerWidthEffect } from '../../components/NavigationDrawerWidthEffect'; import { NavigationDrawerBackButton } from './NavigationDrawerBackButton'; import { NavigationDrawerHeader } from './NavigationDrawerHeader'; @@ -19,9 +24,23 @@ export type NavigationDrawerProps = { title: string; }; -const StyledAnimatedContainer = styled(motion.div)` +const StyledAnimatedContainer = styled.div<{ + isExpanded: boolean; + isResizing: boolean; +}>` max-height: 100vh; overflow: hidden; + position: relative; + width: ${({ isExpanded }) => + isExpanded + ? `var(${NAVIGATION_DRAWER_WIDTH_VAR})` + : `${NAVIGATION_DRAWER_COLLAPSED_WIDTH}px`}; + transition: ${({ isResizing, theme }) => + isResizing ? 'none' : `width ${theme.animation.duration.normal}s`}; + + @media (max-width: ${MOBILE_VIEWPORT}px) { + width: ${({ isExpanded }) => (isExpanded ? '100%' : '0')}; + } `; const StyledContainer = styled.div<{ @@ -31,8 +50,7 @@ const StyledContainer = styled.div<{ box-sizing: border-box; display: flex; flex-direction: column; - width: ${({ isSettings }) => - isSettings ? '100%' : NAV_DRAWER_WIDTHS.menu.desktop.expanded + 'px'}; + width: var(${NAVIGATION_DRAWER_WIDTH_VAR}); gap: ${({ theme }) => theme.spacing(3)}; height: 100%; padding: ${({ theme, isSettings, isMobile }) => @@ -54,11 +72,17 @@ export const NavigationDrawer = ({ title, }: NavigationDrawerProps) => { const [isHovered, setIsHovered] = useState(false); + const [isResizing, setIsResizing] = useState(false); const isMobile = useIsMobile(); const isSettingsDrawer = useIsSettingsDrawer(); - const theme = useTheme(); - const isNavigationDrawerExpanded = useRecoilValue( - isNavigationDrawerExpandedState, + + const [isNavigationDrawerExpanded, setIsNavigationDrawerExpanded] = + useRecoilState(isNavigationDrawerExpandedState); + const [navigationDrawerWidth, setNavigationDrawerWidth] = useRecoilState( + navigationDrawerWidthState, + ); + const setTableWidthResizeIsActive = useSetRecoilState( + tableWidthResizeIsActiveState, ); const handleHover = () => { @@ -69,40 +93,62 @@ export const NavigationDrawer = ({ setIsHovered(false); }; - const desktopWidth = isNavigationDrawerExpanded - ? NAV_DRAWER_WIDTHS.menu.desktop.expanded - : NAV_DRAWER_WIDTHS.menu.desktop.collapsed; + const handleCollapse = useCallback(() => { + setIsNavigationDrawerExpanded(false); + setIsResizing(false); + setTableWidthResizeIsActive(true); + }, [setIsNavigationDrawerExpanded, setTableWidthResizeIsActive]); - const mobileWidth = isNavigationDrawerExpanded - ? NAV_DRAWER_WIDTHS.menu.mobile.expanded - : NAV_DRAWER_WIDTHS.menu.mobile.collapsed; + const handleWidthChange = useCallback( + (width: number) => { + setNavigationDrawerWidth(width); + setIsResizing(false); + setTableWidthResizeIsActive(true); + }, + [setNavigationDrawerWidth, setTableWidthResizeIsActive], + ); - const navigationDrawerAnimate = { - width: isMobile ? mobileWidth : desktopWidth, - opacity: isNavigationDrawerExpanded || !isSettingsDrawer ? 1 : 0, - }; + const handleResizeStart = useCallback(() => { + setIsResizing(true); + setTableWidthResizeIsActive(false); + }, [setTableWidthResizeIsActive]); return ( - - + + - {isSettingsDrawer && title ? ( - !isMobile && - ) : ( - - )} + + {isSettingsDrawer && title ? ( + !isMobile && + ) : ( + + )} - {children} - - + {children} + + + {isNavigationDrawerExpanded && !isMobile && !isSettingsDrawer && ( + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx index a96ad4f9592..836fcac13a6 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx @@ -2,7 +2,7 @@ import { t } from '@lingui/core/macro'; import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; import { NavigationDrawerItemBreadcrumb } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemBreadcrumb'; -import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths'; +import { NAVIGATION_DRAWER_COLLAPSED_WIDTH } from '@/ui/layout/resizable-panel/constants/NavigationDrawerCollapsedWidth'; import { useNavigationDrawerTooltip } from '@/ui/navigation/navigation-drawer/hooks/useNavigationDrawerTooltip'; import { type NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState'; import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; @@ -104,7 +104,7 @@ const StyledItem = styled('button', { width: ${(props) => !props.isNavigationDrawerExpanded - ? `calc(${NAV_DRAWER_WIDTHS.menu.desktop.collapsed}px - ${props.theme.spacing(6)})` + ? `calc(${NAVIGATION_DRAWER_COLLAPSED_WIDTH}px - ${props.theme.spacing(6)})` : `calc(100% - ${props.theme.spacing(1.5)})`}; ${({ isDragging }) => diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/NavDrawerWidths.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/NavDrawerWidths.ts deleted file mode 100644 index 3abe7007980..00000000000 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/NavDrawerWidths.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const NAV_DRAWER_WIDTHS = { - menu: { - mobile: { - collapsed: 0, - expanded: '100%', - }, - desktop: { - collapsed: 40, - expanded: 220, - }, - }, -}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState.ts index 05fd34d432a..d4f84294834 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState.ts @@ -1,5 +1,5 @@ import { atom } from 'recoil'; -import { localStorageEffect } from '~/utils/recoil-effects'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; export const isAdvancedModeEnabledState = atom({ key: 'isAdvancedModeEnabledAtom', diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState.ts index 994b89fb40c..8044e652ee6 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState.ts @@ -1,5 +1,5 @@ import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -import { localStorageEffect } from '~/utils/recoil-effects'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; export const isNavigationSectionOpenFamilyState = createFamilyState< boolean, diff --git a/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerExpanded.ts b/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerExpanded.ts index 84de68556d3..e81b079b64f 100644 --- a/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerExpanded.ts +++ b/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerExpanded.ts @@ -1,6 +1,6 @@ import { atom } from 'recoil'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; -import { localStorageEffect } from '~/utils/recoil-effects'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; const isMobile = window.innerWidth <= MOBILE_VIEWPORT; diff --git a/packages/twenty-front/src/modules/ui/navigation/states/navigationDrawerWidthState.ts b/packages/twenty-front/src/modules/ui/navigation/states/navigationDrawerWidthState.ts new file mode 100644 index 00000000000..9cb0ee89d04 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/states/navigationDrawerWidthState.ts @@ -0,0 +1,12 @@ +import { atom } from 'recoil'; + +import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; + +export const NAVIGATION_DRAWER_WIDTH_VAR = '--navigation-drawer-width'; + +export const navigationDrawerWidthState = atom({ + key: 'navigationDrawerWidth', + default: NAVIGATION_DRAWER_CONSTRAINTS.default, + effects: [localStorageEffect()], +}); diff --git a/packages/twenty-front/src/modules/ui/theme/states/persistedColorSchemeState.ts b/packages/twenty-front/src/modules/ui/theme/states/persistedColorSchemeState.ts index 19d64757ec3..0c17a620af0 100644 --- a/packages/twenty-front/src/modules/ui/theme/states/persistedColorSchemeState.ts +++ b/packages/twenty-front/src/modules/ui/theme/states/persistedColorSchemeState.ts @@ -1,7 +1,7 @@ import { atom } from 'recoil'; import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember'; -import { localStorageEffect } from '~/utils/recoil-effects'; +import { localStorageEffect } from '~/utils/recoil/localStorageEffect'; export const persistedColorSchemeState = atom({ key: 'persistedColorSchemeState', diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx index 893aa966158..61ef5165f22 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx @@ -1,5 +1,5 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; -import { COMMAND_MENU_SIDE_PANEL_WIDTH } from '@/command-menu/constants/CommandMenuSidePanelWidth'; +import { commandMenuWidthState } from '@/command-menu/states/commandMenuWidthState'; import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { useListenToSidePanelClosing } from '@/ui/layout/right-drawer/hooks/useListenToSidePanelClosing'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; @@ -222,7 +222,7 @@ export const WorkflowDiagramCanvasBase = ({ const containerRef = useRef(null); const setFlowViewport = useRecoilCallback( - () => + ({ snapshot }) => ({ workflowDiagramFlowInitialized, isCommandMenuOpened, @@ -260,12 +260,15 @@ export const WorkflowDiagramCanvasBase = ({ let adjustedContainerWidth = baseContainerWidth; + const commandMenuWidth = getSnapshotValue( + snapshot, + commandMenuWidthState, + ); + if (!isInRightDrawer && isCommandMenuOpened) { - adjustedContainerWidth = - baseContainerWidth - COMMAND_MENU_SIDE_PANEL_WIDTH; + adjustedContainerWidth = baseContainerWidth - commandMenuWidth; } else if (!isInRightDrawer && hasViewportBeenMoved) { - adjustedContainerWidth = - baseContainerWidth + COMMAND_MENU_SIDE_PANEL_WIDTH; + adjustedContainerWidth = baseContainerWidth + commandMenuWidth; } const flowBounds = reactflow.getNodesBounds(nodes); diff --git a/packages/twenty-front/src/utils/recoil-effects.ts b/packages/twenty-front/src/utils/recoil/cookieStorageEffect.ts similarity index 79% rename from packages/twenty-front/src/utils/recoil-effects.ts rename to packages/twenty-front/src/utils/recoil/cookieStorageEffect.ts index 6876db6dca5..a7f6925a63d 100644 --- a/packages/twenty-front/src/utils/recoil-effects.ts +++ b/packages/twenty-front/src/utils/recoil/cookieStorageEffect.ts @@ -4,21 +4,6 @@ import { isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; import { cookieStorage } from '~/utils/cookie-storage'; -export const localStorageEffect = - (key?: string): AtomEffect => - ({ setSelf, onSet, node }) => { - const savedValue = localStorage.getItem(key ?? node.key); - if (savedValue != null) { - setSelf(JSON.parse(savedValue)); - } - - onSet((newValue, _, isReset) => { - isReset - ? localStorage.removeItem(key ?? node.key) - : localStorage.setItem(key ?? node.key, JSON.stringify(newValue)); - }); - }; - const customCookieAttributeZodSchema = z.object({ cookieAttributes: z.object({ expires: z.union([z.number(), z.instanceof(Date)]).optional(), @@ -28,7 +13,7 @@ const customCookieAttributeZodSchema = z.object({ }), }); -export const isCustomCookiesAttributesValue = ( +const isCustomCookiesAttributesValue = ( value: unknown, ): value is { cookieAttributes: Cookies.CookieAttributes } => customCookieAttributeZodSchema.safeParse(value).success; diff --git a/packages/twenty-front/src/utils/recoil/localStorageEffect.ts b/packages/twenty-front/src/utils/recoil/localStorageEffect.ts new file mode 100644 index 00000000000..df36e3225ca --- /dev/null +++ b/packages/twenty-front/src/utils/recoil/localStorageEffect.ts @@ -0,0 +1,21 @@ +import { type AtomEffect } from 'recoil'; + +export const localStorageEffect = + (key?: string): AtomEffect => + ({ setSelf, onSet, node }) => { + const savedValue = localStorage.getItem(key ?? node.key); + if (savedValue != null) { + try { + setSelf(JSON.parse(savedValue)); + } catch { + // Invalid JSON in localStorage, ignore and use default value + localStorage.removeItem(key ?? node.key); + } + } + + onSet((newValue, _, isReset) => { + isReset + ? localStorage.removeItem(key ?? node.key) + : localStorage.setItem(key ?? node.key, JSON.stringify(newValue)); + }); + };