feat: add resizable navigation drawer and command menu panels (#16612)

## Summary

Adds Notion-style resizable panels for the navigation drawer (left
sidebar) and command menu (right panel).

## Behavior

- **Hover** at panel edge → resize cursor appears
- **Click** → collapse/close the panel
- **Drag** → resize the panel (5px movement threshold to distinguish
from click)

## Constraints

| Panel | Min | Max | Default | Collapse Threshold |
|-------|-----|-----|---------|-------------------|
| Navigation Drawer | 180px | 350px | 220px | 150px |
| Command Menu | 320px | 600px | 400px | 250px |

## Performance Optimizations

- **CSS variables** for smooth 60fps resize (no React re-renders during
drag)
- **Table resize observer disabled** during panel resize to prevent
expensive recalculations
- **React.memo wrapper** on page body to prevent unnecessary re-renders

## Architecture

- `useResizablePanel` hook following the same pattern as
`useResizeTableHeader`
- `ResizablePanelEdge` - resize handle positioned at panel edge
- `ResizablePanelGap` - resize handle in the gap between panels
- `cssVariableEffect` - Recoil effect to sync CSS variables with state

## Refactoring

- Split `recoil-effects.ts` into separate files in `utils/recoil/` (one
export per file)
- Persist panel widths to localStorage via existing `localStorageEffect`
This commit is contained in:
Félix Malfait
2025-12-18 09:09:21 +01:00
committed by GitHub
parent 6682d4eb02
commit 4fe8e3d3b6
35 changed files with 671 additions and 206 deletions
@@ -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 = () => {
<StyledAnimatedContainer
initial={false}
animate={{
width: isMobile
? NAV_DRAWER_WIDTHS.menu.mobile.collapsed
: NAV_DRAWER_WIDTHS.menu.desktop.expanded,
width: isMobile ? 0 : NAVIGATION_DRAWER_CONSTRAINTS.default,
opacity: isMobile ? 0 : 1,
}}
transition={{ duration: ANIMATION.duration.fast }}
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { LeftPanelSkeletonLoader } from '~/loading/components/LeftPanelSkeletonLoader';
import { RightPanelSkeletonLoader } from '~/loading/components/RightPanelSkeletonLoader';
@@ -14,7 +14,7 @@ const StyledContainer = styled.div`
flex-direction: row;
gap: 12px;
height: 100dvh;
min-width: ${NAV_DRAWER_WIDTHS.menu.desktop.expanded}px;
min-width: ${NAVIGATION_DRAWER_CONSTRAINTS.default}px;
width: 100%;
padding: 12px 8px 12px 8px;
overflow: hidden;
@@ -1,6 +1,6 @@
import { createState } from 'twenty-ui/utilities';
import { type AuthTokenPair } from '~/generated/graphql';
import { cookieStorageEffect } from '~/utils/recoil-effects';
import { cookieStorageEffect } from '~/utils/recoil/cookieStorageEffect';
export const tokenPairState = createState<AuthTokenPair | null>({
key: 'tokenPairState',
@@ -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<HTMLDivElement | null>(
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 (
<>
<CommandMenuWidthEffect />
<ResizablePanelGap
side="left"
constraints={COMMAND_MENU_CONSTRAINTS}
currentWidth={commandMenuWidth}
onWidthChange={handleWidthChange}
onCollapse={handleCollapse}
gapWidth={isCommandMenuOpened ? GAP_WIDTH : 0}
cssVariableName={COMMAND_MENU_WIDTH_VAR}
onResizeStart={handleResizeStart}
/>
<StyledSidePanelWrapper
isOpen={isCommandMenuOpened}
isResizing={isResizing}
onTransitionEnd={handleTransitionEnd}
>
<StyledSidePanel>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <CommandMenuRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
</>
);
};
@@ -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;
};
@@ -1 +0,0 @@
export const COMMAND_MENU_SIDE_PANEL_WIDTH = 400;
@@ -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<number>({
key: 'commandMenuWidth',
default: COMMAND_MENU_CONSTRAINTS.default,
effects: [localStorageEffect()],
});
@@ -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<
@@ -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<string | null>({
@@ -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<Record<
@@ -1,17 +1,8 @@
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { COMMAND_MENU_SIDE_PANEL_WIDTH } from '@/command-menu/constants/CommandMenuSidePanelWidth';
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
import { CommandMenuSidePanel } from '@/command-menu/components/CommandMenuSidePanel';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
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 { PageBody } from '@/ui/layout/page/components/PageBody';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { type ReactNode, useCallback, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { type ReactNode } from 'react';
import { useIsMobile } from 'twenty-ui/utilities';
type CommandMenuPageLayoutProps = {
@@ -27,89 +18,17 @@ const StyledLayout = styled.div`
`;
const StyledPageBody = styled(PageBody)`
flex: 1;
flex: 1 1 0;
min-width: 0;
width: 0;
padding-bottom: 0;
padding-right: 0;
`;
const StyledSidePanelWrapper = styled(motion.div)`
flex-shrink: 0;
min-width: 0;
overflow: hidden;
`;
const StyledSidePanel = styled(motion.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: ${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<HTMLDivElement | null>(
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 (
<StyledLayout>
<StyledPageBody>{children}</StyledPageBody>
<StyledSidePanelWrapper
initial={false}
animate={{
width: isCommandMenuOpened ? COMMAND_MENU_SIDE_PANEL_WIDTH : 0,
marginLeft: isCommandMenuOpened ? theme.spacing(2) : 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationStart={handleAnimationStart}
onAnimationComplete={handleAnimationComplete}
>
<StyledSidePanel
initial={false}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <CommandMenuRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
<CommandMenuSidePanel />
</StyledLayout>
);
};
@@ -1,5 +1,5 @@
import { atom } from 'recoil';
import { localStorageEffect } from '~/utils/recoil-effects';
import { localStorageEffect } from '~/utils/recoil/localStorageEffect';
export const playgroundApiKeyState = atom<string | null>({
key: 'playgroundApiKeyState',
@@ -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,
@@ -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<StyledEdgeProps>`
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 (
<StyledEdge
side={side}
isActive={isResizing}
isHovered={isHovered}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showHandle && (
<StyledHandle isActive={isResizing} isHovered={isHovered} />
)}
</StyledEdge>
);
};
@@ -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 (
<StyledGap
gapWidth={gapWidth}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};
@@ -0,0 +1,7 @@
import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints';
export const COMMAND_MENU_CONSTRAINTS: ResizablePanelConstraints = {
min: 320,
max: 600,
default: 400,
};
@@ -0,0 +1 @@
export const NAVIGATION_DRAWER_COLLAPSED_WIDTH = 40;
@@ -0,0 +1,7 @@
import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints';
export const NAVIGATION_DRAWER_CONSTRAINTS: ResizablePanelConstraints = {
min: 180,
max: 350,
default: 220,
};
@@ -0,0 +1 @@
export const RESIZE_DRAG_THRESHOLD_PX = 5;
@@ -0,0 +1 @@
export const RESIZE_EDGE_WIDTH_PX = 8;
@@ -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<number | null>(null);
const [startWidth, setStartWidth] = useState<number>(0);
const [hasDragged, setHasDragged] = useState(false);
const handleResizeMove = useCallback<PointerEventListener>(
({ 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<PointerEventListener>(
({ 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,
};
};
@@ -0,0 +1,5 @@
export type ResizablePanelConstraints = {
min: number;
max: number;
default: number;
};
@@ -0,0 +1 @@
export type ResizablePanelSide = 'left' | 'right';
@@ -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;
};
@@ -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 (
<StyledAnimatedContainer
className={className}
initial={false}
animate={navigationDrawerAnimate}
transition={{ duration: theme.animation.duration.normal }}
>
<StyledContainer
isSettings={isSettingsDrawer}
isMobile={isMobile}
onMouseEnter={handleHover}
onMouseLeave={handleMouseLeave}
<>
<NavigationDrawerWidthEffect />
<StyledAnimatedContainer
className={className}
isExpanded={isNavigationDrawerExpanded}
isResizing={isResizing}
>
{isSettingsDrawer && title ? (
!isMobile && <NavigationDrawerBackButton title={title} />
) : (
<NavigationDrawerHeader showCollapseButton={isHovered} />
)}
<StyledContainer
isSettings={isSettingsDrawer}
isMobile={isMobile}
onMouseEnter={handleHover}
onMouseLeave={handleMouseLeave}
>
{isSettingsDrawer && title ? (
!isMobile && <NavigationDrawerBackButton title={title} />
) : (
<NavigationDrawerHeader showCollapseButton={isHovered} />
)}
{children}
</StyledContainer>
</StyledAnimatedContainer>
{children}
</StyledContainer>
{isNavigationDrawerExpanded && !isMobile && !isSettingsDrawer && (
<ResizablePanelEdge
side="right"
constraints={NAVIGATION_DRAWER_CONSTRAINTS}
currentWidth={navigationDrawerWidth}
onWidthChange={handleWidthChange}
onCollapse={handleCollapse}
showHandle={false}
cssVariableName={NAVIGATION_DRAWER_WIDTH_VAR}
onResizeStart={handleResizeStart}
/>
)}
</StyledAnimatedContainer>
</>
);
};
@@ -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 }) =>
@@ -1,12 +0,0 @@
export const NAV_DRAWER_WIDTHS = {
menu: {
mobile: {
collapsed: 0,
expanded: '100%',
},
desktop: {
collapsed: 40,
expanded: 220,
},
},
};
@@ -1,5 +1,5 @@
import { atom } from 'recoil';
import { localStorageEffect } from '~/utils/recoil-effects';
import { localStorageEffect } from '~/utils/recoil/localStorageEffect';
export const isAdvancedModeEnabledState = atom<boolean>({
key: 'isAdvancedModeEnabledAtom',
@@ -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,
@@ -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;
@@ -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<number>({
key: 'navigationDrawerWidth',
default: NAVIGATION_DRAWER_CONSTRAINTS.default,
effects: [localStorageEffect()],
});
@@ -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<ColorScheme>({
key: 'persistedColorSchemeState',
@@ -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<HTMLDivElement>(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);
@@ -4,21 +4,6 @@ import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { cookieStorage } from '~/utils/cookie-storage';
export const localStorageEffect =
<T>(key?: string): AtomEffect<T> =>
({ 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;
@@ -0,0 +1,21 @@
import { type AtomEffect } from 'recoil';
export const localStorageEffect =
<T>(key?: string): AtomEffect<T> =>
({ 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));
});
};