Skip to content

Commit f84d9b8

Browse files
MichaelSun48billyvg
authored andcommitted
feat(nav, resizing): Make secondary nav resizible with new performant hook (#91546)
Makes the secondary nav resizable via a new hook, `useResizable`. `useResizable` consumes a container ref and updates its width directly, rather than storing and returning a `size` state like the previous `useResizableDrawer`, making it feel significantly more performant. It also ensures that the cursor is always attached to the drag handle while dragging, which was an issue with `useResizableDrawer`. Besides these changes, the API is similar to `useResizableDrawer` and supports params like `onResizeStart`, `onResizeEnd`, and a `isHeld` return param. This hook only supports horizontal dragging, but it shouldn't be too hard to extend it to support vertical dragging as well. https://github.com/user-attachments/assets/c5b5c00f-d258-4750-a4e2-b68b60844729
1 parent 0363ba0 commit f84d9b8

File tree

4 files changed

+263
-24
lines changed

4 files changed

+263
-24
lines changed

static/app/utils/useResizable.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type {RefObject} from 'react';
2+
import {useCallback, useEffect, useRef, useState} from 'react';
3+
4+
const RESIZABLE_DEFAULT_WIDTH = 200;
5+
const RESIZABLE_MIN_WIDTH = 100;
6+
const RESIZABLE_MAX_WIDTH = Infinity;
7+
8+
interface UseResizableOptions {
9+
/**
10+
* The ref to the element to be resized.
11+
*/
12+
ref: RefObject<HTMLElement | null>;
13+
14+
/**
15+
* The starting size of the container, and the size that is set in the onDoubleClick handler.
16+
*
17+
* If `sizeStorageKey` is provided and exists in local storage,
18+
* then this will be ignored in favor of the size stored in local storage.
19+
*/
20+
initialSize?: number;
21+
22+
/**
23+
* The maximum width the container can be resized to. Defaults to Infinity.
24+
*/
25+
maxWidth?: number;
26+
27+
/**
28+
* The minimum width the container can be resized to. Defaults to 100.
29+
*/
30+
minWidth?: number;
31+
32+
/**
33+
* Triggered when the user finishes dragging the resize handle.
34+
*/
35+
onResizeEnd?: (newWidth: number) => void;
36+
37+
/**
38+
* Triggered when the user starts dragging the resize handle.
39+
*/
40+
onResizeStart?: () => void;
41+
}
42+
43+
/**
44+
* Performant hook to support draggable container resizing.
45+
*
46+
* Currently only supports resizing width and not height.
47+
*/
48+
const useResizable = ({
49+
ref,
50+
initialSize = RESIZABLE_DEFAULT_WIDTH,
51+
maxWidth = RESIZABLE_MAX_WIDTH,
52+
minWidth = RESIZABLE_MIN_WIDTH,
53+
onResizeEnd,
54+
onResizeStart,
55+
}: UseResizableOptions): {
56+
/**
57+
* Whether the drag handle is held.
58+
*/
59+
isHeld: boolean;
60+
/**
61+
* Apply this to the drag handle element to include 'reset' functionality.
62+
*/
63+
onDoubleClick: () => void;
64+
/**
65+
* Attach this to the drag handle element's onMouseDown handler.
66+
*/
67+
onMouseDown: (e: React.MouseEvent) => void;
68+
/**
69+
* The current size of the container. This is NOT updated during the drag
70+
* event, only after the user finishes dragging.
71+
*/
72+
size: number;
73+
} => {
74+
const [isHeld, setIsHeld] = useState(false);
75+
76+
const isDraggingRef = useRef<boolean>(false);
77+
const startXRef = useRef<number>(0);
78+
const startWidthRef = useRef<number>(0);
79+
80+
useEffect(() => {
81+
if (ref.current) {
82+
ref.current.style.width = `${initialSize}px`;
83+
}
84+
}, [ref, initialSize]);
85+
86+
const handleMouseDown = useCallback(
87+
(e: React.MouseEvent) => {
88+
setIsHeld(true);
89+
e.preventDefault();
90+
91+
const currentWidth = ref.current
92+
? parseInt(getComputedStyle(ref.current).width, 10)
93+
: 0;
94+
95+
isDraggingRef.current = true;
96+
startXRef.current = e.clientX;
97+
startWidthRef.current = currentWidth;
98+
99+
document.body.style.cursor = 'ew-resize';
100+
document.body.style.userSelect = 'none';
101+
onResizeStart?.();
102+
},
103+
[ref, onResizeStart]
104+
);
105+
106+
const handleMouseMove = useCallback(
107+
(e: MouseEvent) => {
108+
if (!isDraggingRef.current) return;
109+
110+
const deltaX = e.clientX - startXRef.current;
111+
const newWidth = Math.max(
112+
minWidth,
113+
Math.min(maxWidth, startWidthRef.current + deltaX)
114+
);
115+
116+
if (ref.current) {
117+
ref.current.style.width = `${newWidth}px`;
118+
}
119+
},
120+
[ref, minWidth, maxWidth]
121+
);
122+
123+
const handleMouseUp = useCallback(() => {
124+
setIsHeld(false);
125+
const newSize = ref.current?.offsetWidth ?? initialSize;
126+
isDraggingRef.current = false;
127+
document.body.style.cursor = '';
128+
document.body.style.userSelect = '';
129+
onResizeEnd?.(newSize);
130+
}, [onResizeEnd, ref, initialSize]);
131+
132+
useEffect(() => {
133+
document.addEventListener('mousemove', handleMouseMove);
134+
document.addEventListener('mouseup', handleMouseUp);
135+
136+
return () => {
137+
document.removeEventListener('mousemove', handleMouseMove);
138+
document.removeEventListener('mouseup', handleMouseUp);
139+
};
140+
}, [handleMouseMove, handleMouseUp]);
141+
142+
const onDoubleClick = useCallback(() => {
143+
if (ref.current) {
144+
ref.current.style.width = `${initialSize}px`;
145+
}
146+
}, [ref, initialSize]);
147+
148+
return {
149+
isHeld,
150+
size: ref.current?.offsetWidth ?? initialSize,
151+
onMouseDown: handleMouseDown,
152+
onDoubleClick,
153+
};
154+
};
155+
156+
export default useResizable;

static/app/views/nav/constants.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY = 'navigation-sidebar-is-co
22

33
export const PRIMARY_SIDEBAR_WIDTH = 74;
44
export const SECONDARY_SIDEBAR_WIDTH = 190;
5+
export const SECONDARY_SIDEBAR_MIN_WIDTH = 150;
6+
export const SECONDARY_SIDEBAR_MAX_WIDTH = 500;
57

68
// Slightly delay closing the nav to prevent accidental dismissal
79
export const NAV_SIDEBAR_COLLAPSE_DELAY_MS = 200;
Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
import {useRef} from 'react';
12
import styled from '@emotion/styled';
23
import {AnimatePresence, motion} from 'framer-motion';
34

4-
import {SECONDARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants';
5+
import useResizable from 'sentry/utils/useResizable';
6+
import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
7+
import {
8+
SECONDARY_SIDEBAR_MAX_WIDTH,
9+
SECONDARY_SIDEBAR_MIN_WIDTH,
10+
SECONDARY_SIDEBAR_WIDTH,
11+
} from 'sentry/views/nav/constants';
512
import {useNavContext} from 'sentry/views/nav/context';
613
import {SecondaryNav} from 'sentry/views/nav/secondary/secondary';
714
import {SecondaryNavContent} from 'sentry/views/nav/secondary/secondaryNavContent';
@@ -16,42 +23,74 @@ import {useActiveNavGroup} from 'sentry/views/nav/useActiveNavGroup';
1623
export function SecondarySidebar() {
1724
const {currentStepId} = useStackedNavigationTour();
1825
const stepId = currentStepId ?? StackedNavigationTour.ISSUES;
26+
const resizableContainerRef = useRef<HTMLDivElement>(null);
27+
const resizeHandleRef = useRef<HTMLDivElement>(null);
28+
29+
const [secondarySidebarWidth, setSecondarySidebarWidth] = useSyncedLocalStorageState(
30+
'secondary-sidebar-width',
31+
SECONDARY_SIDEBAR_WIDTH
32+
);
33+
34+
const {
35+
onMouseDown: handleStartResize,
36+
size,
37+
onDoubleClick,
38+
} = useResizable({
39+
ref: resizableContainerRef,
40+
initialSize: secondarySidebarWidth,
41+
minWidth: SECONDARY_SIDEBAR_MIN_WIDTH,
42+
maxWidth: SECONDARY_SIDEBAR_MAX_WIDTH,
43+
onResizeEnd: newWidth => {
44+
setSecondarySidebarWidth(newWidth);
45+
},
46+
});
47+
1948
const {activePrimaryNavGroup} = useNavContext();
2049
const defaultActiveNavGroup = useActiveNavGroup();
2150

2251
const activeNavGroup = activePrimaryNavGroup ?? defaultActiveNavGroup;
2352

2453
return (
25-
<SecondarySidebarWrapper
26-
id={stepId}
27-
description={STACKED_NAVIGATION_TOUR_CONTENT[stepId].description}
28-
title={STACKED_NAVIGATION_TOUR_CONTENT[stepId].title}
29-
>
30-
<AnimatePresence mode="wait" initial={false}>
31-
<MotionDiv
32-
key={activeNavGroup}
33-
initial={{x: -4, opacity: 0}}
34-
animate={{x: 0, opacity: 1}}
35-
exit={{x: 4, opacity: 0}}
36-
transition={{duration: 0.06}}
37-
>
38-
<SecondarySidebarInner>
39-
<SecondaryNavContent group={activeNavGroup} />
40-
</SecondarySidebarInner>
41-
</MotionDiv>
42-
</AnimatePresence>
43-
</SecondarySidebarWrapper>
54+
<ResizeWrapper ref={resizableContainerRef} onMouseDown={handleStartResize}>
55+
<NavTourElement
56+
id={stepId}
57+
description={STACKED_NAVIGATION_TOUR_CONTENT[stepId].description}
58+
title={STACKED_NAVIGATION_TOUR_CONTENT[stepId].title}
59+
>
60+
<AnimatePresence mode="wait" initial={false}>
61+
<MotionDiv
62+
key={activeNavGroup}
63+
initial={{x: -4, opacity: 0}}
64+
animate={{x: 0, opacity: 1}}
65+
exit={{x: 4, opacity: 0}}
66+
transition={{duration: 0.06}}
67+
>
68+
<SecondarySidebarInner>
69+
<SecondaryNavContent group={activeNavGroup} />
70+
</SecondarySidebarInner>
71+
<ResizeHandle
72+
ref={resizeHandleRef}
73+
onMouseDown={handleStartResize}
74+
onDoubleClick={onDoubleClick}
75+
atMinWidth={size === SECONDARY_SIDEBAR_MIN_WIDTH}
76+
atMaxWidth={size === SECONDARY_SIDEBAR_MAX_WIDTH}
77+
/>
78+
</MotionDiv>
79+
</AnimatePresence>
80+
</NavTourElement>
81+
</ResizeWrapper>
4482
);
4583
}
4684

47-
const SecondarySidebarWrapper = styled(NavTourElement)`
85+
const ResizeWrapper = styled('div')`
4886
position: relative;
87+
right: 0;
4988
border-right: 1px solid
5089
${p => (p.theme.isChonk ? p.theme.border : p.theme.translucentGray200)};
5190
background: ${p => (p.theme.isChonk ? p.theme.background : p.theme.surface200)};
52-
width: ${SECONDARY_SIDEBAR_WIDTH}px;
5391
z-index: ${p => p.theme.zIndex.sidebarPanel};
5492
height: 100%;
93+
width: ${SECONDARY_SIDEBAR_WIDTH}px;
5594
`;
5695

5796
const SecondarySidebarInner = styled(SecondaryNav)`
@@ -62,3 +101,33 @@ const MotionDiv = styled(motion.div)`
62101
height: 100%;
63102
width: 100%;
64103
`;
104+
105+
const ResizeHandle = styled('div')<{atMaxWidth: boolean; atMinWidth: boolean}>`
106+
position: absolute;
107+
right: 0px;
108+
top: 0;
109+
bottom: 0;
110+
width: 8px;
111+
border-radius: 8px;
112+
z-index: ${p => p.theme.zIndex.drawer + 2};
113+
cursor: ${p => (p.atMinWidth ? 'e-resize' : p.atMaxWidth ? 'w-resize' : 'ew-resize')};
114+
115+
&:hover,
116+
&:active {
117+
&::after {
118+
background: ${p => p.theme.purple400};
119+
}
120+
}
121+
122+
&::after {
123+
content: '';
124+
position: absolute;
125+
right: -2px;
126+
top: 0;
127+
bottom: 0;
128+
width: 4px;
129+
opacity: 0.8;
130+
background: transparent;
131+
transition: background 0.25s ease 0.1s;
132+
}
133+
`;

static/app/views/nav/sidebar.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
1111
import {chonkStyled} from 'sentry/utils/theme/theme.chonk';
1212
import {withChonk} from 'sentry/utils/theme/withChonk';
1313
import useOrganization from 'sentry/utils/useOrganization';
14+
import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
1415
import {PRIMARY_SIDEBAR_WIDTH, SECONDARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants';
1516
import {useNavContext} from 'sentry/views/nav/context';
1617
import {OrgDropdown} from 'sentry/views/nav/orgDropdown';
@@ -37,6 +38,11 @@ export function Sidebar() {
3738
const isCollapsed = forceExpanded ? false : isCollapsedState;
3839
const {isOpen} = useCollapsedNav();
3940

41+
const [secondarySidebarWidth] = useSyncedLocalStorageState(
42+
'secondary-sidebar-width',
43+
SECONDARY_SIDEBAR_WIDTH
44+
);
45+
4046
useTourModal();
4147

4248
return (
@@ -63,9 +69,15 @@ export function Sidebar() {
6369
animate={isOpen ? 'visible' : 'hidden'}
6470
variants={{
6571
visible: {x: 0},
66-
hidden: {x: -SECONDARY_SIDEBAR_WIDTH - 10},
72+
hidden: {x: -secondarySidebarWidth - 10},
73+
}}
74+
transition={{
75+
type: 'spring',
76+
damping: 50,
77+
stiffness: 700,
78+
bounce: 0,
79+
visualDuration: 0.1,
6780
}}
68-
transition={{duration: 0.15, ease: 'easeOut'}}
6981
data-test-id="collapsed-secondary-sidebar"
7082
data-visible={isOpen}
7183
>

0 commit comments

Comments
 (0)