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:
@@ -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';
|
||||
Reference in New Issue
Block a user