Skip to content

Commit 03bc306

Browse files
committed
Add new performant hook for resizing
1 parent 1a52a26 commit 03bc306

File tree

3 files changed

+245
-14
lines changed

3 files changed

+245
-14
lines changed

static/app/utils/useResizable.tsx

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type {RefObject} from 'react';
2+
import {useCallback, useEffect, useRef, useState} from 'react';
3+
4+
export const RESIZABLE_DEFAULT_WIDTH = 200;
5+
export const RESIZABLE_MIN_WIDTH = 100;
6+
export 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+
* The local storage key used to persist the size of the container. If not provided,
44+
* the size will not be persisted and the defaultWidth will be used.
45+
*/
46+
sizeStorageKey?: string;
47+
}
48+
49+
/**
50+
* Performant hook to support draggable container resizing.
51+
*
52+
* Currently only supports resizing width and not height.
53+
*/
54+
export const useResizable = ({
55+
ref,
56+
initialSize = RESIZABLE_DEFAULT_WIDTH,
57+
maxWidth = RESIZABLE_MAX_WIDTH,
58+
minWidth = RESIZABLE_MIN_WIDTH,
59+
onResizeEnd,
60+
onResizeStart,
61+
sizeStorageKey,
62+
}: UseResizableOptions): {
63+
/**
64+
* Whether the drag handle is held.
65+
*/
66+
isHeld: boolean;
67+
/**
68+
* Apply this to the drag handle element to include 'reset' functionality.
69+
*/
70+
onDoubleClick: () => void;
71+
/**
72+
* Attach this to the drag handle element's onMouseDown handler.
73+
*/
74+
onMouseDown: (e: React.MouseEvent) => void;
75+
/**
76+
* The current size of the container. This is NOT updated during the drag
77+
* event, only after the user finishes dragging.
78+
*/
79+
size: number;
80+
} => {
81+
const [isHeld, setIsHeld] = useState(false);
82+
83+
const isDraggingRef = useRef<boolean>(false);
84+
const startXRef = useRef<number>(0);
85+
const startWidthRef = useRef<number>(0);
86+
87+
useEffect(() => {
88+
if (ref.current) {
89+
const storedSize = sizeStorageKey
90+
? parseInt(localStorage.getItem(sizeStorageKey) ?? '', 10)
91+
: undefined;
92+
93+
ref.current.style.width = `${storedSize ?? initialSize}px`;
94+
}
95+
}, [ref, initialSize, sizeStorageKey]);
96+
97+
const handleMouseDown = useCallback(
98+
(e: React.MouseEvent) => {
99+
setIsHeld(true);
100+
e.preventDefault();
101+
102+
const currentWidth = ref.current
103+
? parseInt(getComputedStyle(ref.current).width, 10)
104+
: 0;
105+
106+
isDraggingRef.current = true;
107+
startXRef.current = e.clientX;
108+
startWidthRef.current = currentWidth;
109+
110+
document.body.style.cursor = 'ew-resize';
111+
document.body.style.userSelect = 'none';
112+
onResizeStart?.();
113+
},
114+
[ref, onResizeStart]
115+
);
116+
117+
const handleMouseMove = useCallback(
118+
(e: MouseEvent) => {
119+
if (!isDraggingRef.current) return;
120+
121+
const deltaX = e.clientX - startXRef.current;
122+
const newWidth = Math.max(
123+
minWidth,
124+
Math.min(maxWidth, startWidthRef.current + deltaX)
125+
);
126+
127+
if (ref.current) {
128+
ref.current.style.width = `${newWidth}px`;
129+
}
130+
},
131+
[ref, minWidth, maxWidth]
132+
);
133+
134+
const handleMouseUp = useCallback(() => {
135+
setIsHeld(false);
136+
const newSize = ref.current?.offsetWidth ?? initialSize;
137+
isDraggingRef.current = false;
138+
document.body.style.cursor = '';
139+
document.body.style.userSelect = '';
140+
onResizeEnd?.(newSize);
141+
if (sizeStorageKey) {
142+
localStorage.setItem(sizeStorageKey, newSize.toString());
143+
}
144+
}, [onResizeEnd, ref, sizeStorageKey, initialSize]);
145+
146+
useEffect(() => {
147+
document.addEventListener('mousemove', handleMouseMove);
148+
document.addEventListener('mouseup', handleMouseUp);
149+
150+
return () => {
151+
document.removeEventListener('mousemove', handleMouseMove);
152+
document.removeEventListener('mouseup', handleMouseUp);
153+
};
154+
}, [handleMouseMove, handleMouseUp]);
155+
156+
const onDoubleClick = useCallback(() => {
157+
if (ref.current) {
158+
ref.current.style.width = `${initialSize}px`;
159+
}
160+
}, [ref, initialSize]);
161+
162+
return {
163+
isHeld,
164+
size: ref.current?.offsetWidth ?? initialSize,
165+
onMouseDown: handleMouseDown,
166+
onDoubleClick,
167+
};
168+
};
169+
170+
export default useResizable;

static/app/views/nav/constants.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ 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 = 100;
6+
export const SECONDARY_SIDEBAR_MAX_WIDTH = 500;
Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import {useRef} from 'react';
12
import styled from '@emotion/styled';
23

3-
import {SECONDARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants';
4+
import useResizable from 'sentry/utils/useResizable';
5+
import {
6+
SECONDARY_SIDEBAR_MAX_WIDTH,
7+
SECONDARY_SIDEBAR_MIN_WIDTH,
8+
SECONDARY_SIDEBAR_WIDTH,
9+
} from 'sentry/views/nav/constants';
410
import {useNavContext} from 'sentry/views/nav/context';
511
import {
612
NavTourElement,
@@ -13,28 +19,51 @@ export function SecondarySidebar() {
1319
const {setSecondaryNavEl} = useNavContext();
1420
const {currentStepId} = useStackedNavigationTour();
1521
const stepId = currentStepId ?? StackedNavigationTour.ISSUES;
22+
const resizableContainerRef = useRef<HTMLDivElement>(null);
23+
const resizeHandleRef = useRef<HTMLDivElement>(null);
24+
25+
const {
26+
onMouseDown: handleStartResize,
27+
size,
28+
onDoubleClick,
29+
} = useResizable({
30+
ref: resizableContainerRef,
31+
initialSize: SECONDARY_SIDEBAR_WIDTH,
32+
minWidth: SECONDARY_SIDEBAR_MIN_WIDTH,
33+
maxWidth: SECONDARY_SIDEBAR_MAX_WIDTH,
34+
sizeStorageKey: 'secondary-sidebar-width',
35+
});
1636

1737
return (
18-
<SecondarySidebarWrapper
19-
id={stepId}
20-
description={STACKED_NAVIGATION_TOUR_CONTENT[stepId].description}
21-
title={STACKED_NAVIGATION_TOUR_CONTENT[stepId].title}
22-
>
23-
<SecondarySidebarInner
24-
ref={setSecondaryNavEl}
25-
role="navigation"
26-
aria-label="Secondary Navigation"
27-
/>
28-
</SecondarySidebarWrapper>
38+
<ResizeWrapper ref={resizableContainerRef} onMouseDown={handleStartResize}>
39+
<NavTourElement
40+
id={stepId}
41+
description={STACKED_NAVIGATION_TOUR_CONTENT[stepId].description}
42+
title={STACKED_NAVIGATION_TOUR_CONTENT[stepId].title}
43+
>
44+
<SecondarySidebarInner
45+
ref={setSecondaryNavEl}
46+
role="navigation"
47+
aria-label="Secondary Navigation"
48+
/>
49+
<ResizeHandle
50+
ref={resizeHandleRef}
51+
onMouseDown={handleStartResize}
52+
onDoubleClick={onDoubleClick}
53+
atMinWidth={size === SECONDARY_SIDEBAR_MIN_WIDTH}
54+
atMaxWidth={size === SECONDARY_SIDEBAR_MAX_WIDTH}
55+
/>
56+
</NavTourElement>
57+
</ResizeWrapper>
2958
);
3059
}
3160

32-
const SecondarySidebarWrapper = styled(NavTourElement)`
61+
const ResizeWrapper = styled('div')`
3362
position: relative;
63+
right: 0;
3464
border-right: 1px solid
3565
${p => (p.theme.isChonk ? p.theme.border : p.theme.translucentGray200)};
3666
background: ${p => (p.theme.isChonk ? p.theme.background : p.theme.surface200)};
37-
width: ${SECONDARY_SIDEBAR_WIDTH}px;
3867
z-index: ${p => p.theme.zIndex.sidebarPanel};
3968
height: 100%;
4069
`;
@@ -44,3 +73,33 @@ const SecondarySidebarInner = styled('div')`
4473
grid-template-rows: auto 1fr auto;
4574
height: 100%;
4675
`;
76+
77+
const ResizeHandle = styled('div')<{atMaxWidth: boolean; atMinWidth: boolean}>`
78+
position: absolute;
79+
right: 0px;
80+
top: 0;
81+
bottom: 0;
82+
width: 8px;
83+
border-radius: 8px;
84+
z-index: ${p => p.theme.zIndex.drawer + 2};
85+
cursor: ${p => (p.atMinWidth ? 'e-resize' : p.atMaxWidth ? 'w-resize' : 'ew-resize')};
86+
87+
&:hover,
88+
&:active {
89+
&::after {
90+
background: ${p => p.theme.purple400};
91+
}
92+
}
93+
94+
&::after {
95+
content: '';
96+
position: absolute;
97+
right: -2px;
98+
top: 0;
99+
bottom: 0;
100+
width: 4px;
101+
opacity: 0.8;
102+
background: transparent;
103+
transition: background 0.25s ease 0.1s;
104+
}
105+
`;

0 commit comments

Comments
 (0)