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:
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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<
|
||||
|
||||
+5
-110
@@ -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
-1
@@ -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,
|
||||
|
||||
+96
@@ -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>
|
||||
);
|
||||
};
|
||||
+55
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints';
|
||||
|
||||
export const COMMAND_MENU_CONSTRAINTS: ResizablePanelConstraints = {
|
||||
min: 320,
|
||||
max: 600,
|
||||
default: 400,
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const NAVIGATION_DRAWER_COLLAPSED_WIDTH = 40;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type ResizablePanelConstraints } from '../types/ResizablePanelConstraints';
|
||||
|
||||
export const NAVIGATION_DRAWER_CONSTRAINTS: ResizablePanelConstraints = {
|
||||
min: 180,
|
||||
max: 350,
|
||||
default: 220,
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RESIZE_DRAG_THRESHOLD_PX = 5;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RESIZE_EDGE_WIDTH_PX = 8;
|
||||
+145
@@ -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,
|
||||
};
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type ResizablePanelConstraints = {
|
||||
min: number;
|
||||
max: number;
|
||||
default: number;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type ResizablePanelSide = 'left' | 'right';
|
||||
+20
@@ -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;
|
||||
};
|
||||
+88
-42
@@ -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
-2
@@ -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 }) =>
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
export const NAV_DRAWER_WIDTHS = {
|
||||
menu: {
|
||||
mobile: {
|
||||
collapsed: 0,
|
||||
expanded: '100%',
|
||||
},
|
||||
desktop: {
|
||||
collapsed: 40,
|
||||
expanded: 220,
|
||||
},
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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',
|
||||
|
||||
+9
-6
@@ -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);
|
||||
|
||||
+1
-16
@@ -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));
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user