Skip to content

feat(nav, resizing): Make secondary nav resizible with new performant hook #91546

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions static/app/utils/useResizable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type {RefObject} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';

export const RESIZABLE_DEFAULT_WIDTH = 200;
export const RESIZABLE_MIN_WIDTH = 100;
export const RESIZABLE_MAX_WIDTH = Infinity;
Comment on lines +4 to +6
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a hook like this I wouldn't even have defaults. make the callers set these.


interface UseResizableOptions {
/**
* The ref to the element to be resized.
*/
ref: RefObject<HTMLElement | null>;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tiny nit: I usually don't put whitespace between properties. @TkDodo I actually wonder if there's a lint rule we can apply to this. Not sure if spacing is ever needed since we sort anyway.

/**
* The starting size of the container, and the size that is set in the onDoubleClick handler.
*
* If `sizeStorageKey` is provided and exists in local storage,
* then this will be ignored in favor of the size stored in local storage.
*/
initialSize?: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like this shouldn't be something you set, the CSS should just imply this no?


/**
* The maximum width the container can be resized to. Defaults to Infinity.
*/
maxWidth?: number;

/**
* The minimum width the container can be resized to. Defaults to 100.
*/
minWidth?: number;

/**
* Triggered when the user finishes dragging the resize handle.
*/
onResizeEnd?: (newWidth: number) => void;

/**
* Triggered when the user starts dragging the resize handle.
*/
onResizeStart?: () => void;

/**
* The local storage key used to persist the size of the container. If not provided,
* the size will not be persisted and the defaultWidth will be used.
*/
sizeStorageKey?: string;
}

/**
* Performant hook to support draggable container resizing.
*
* Currently only supports resizing width and not height.
*/
export const useResizable = ({
ref,
initialSize = RESIZABLE_DEFAULT_WIDTH,
maxWidth = RESIZABLE_MAX_WIDTH,
minWidth = RESIZABLE_MIN_WIDTH,
Comment on lines +56 to +58
Copy link
Member Author

@MichaelSun48 MichaelSun48 May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered making these non-optional parameters to force people to have an opinion on width config. Would love to get more thoughts on this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah let's do this

onResizeEnd,
onResizeStart,
sizeStorageKey,
}: UseResizableOptions): {
/**
* Whether the drag handle is held.
*/
isHeld: boolean;
/**
* Apply this to the drag handle element to include 'reset' functionality.
*/
onDoubleClick: () => void;
/**
* Attach this to the drag handle element's onMouseDown handler.
*/
onMouseDown: (e: React.MouseEvent) => void;
/**
* The current size of the container. This is NOT updated during the drag
* event, only after the user finishes dragging.
*/
size: number;
} => {
Comment on lines +62 to +80
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this to an interface

interface UseResizeResult { ... }

const [isHeld, setIsHeld] = useState(false);

const isDraggingRef = useRef<boolean>(false);
const startXRef = useRef<number>(0);
const startWidthRef = useRef<number>(0);

useEffect(() => {
if (ref.current) {
const storedSize = sizeStorageKey
? parseInt(localStorage.getItem(sizeStorageKey) ?? '', 10)
: undefined;

ref.current.style.width = `${storedSize ?? initialSize}px`;
}
}, [ref, initialSize, sizeStorageKey]);

const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsHeld(true);
e.preventDefault();

const currentWidth = ref.current
? parseInt(getComputedStyle(ref.current).width, 10)
: 0;

isDraggingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = currentWidth;

document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
onResizeStart?.();
},
[ref, onResizeStart]
);

const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDraggingRef.current) return;

const deltaX = e.clientX - startXRef.current;
const newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidthRef.current + deltaX)
);

if (ref.current) {
ref.current.style.width = `${newWidth}px`;
}
},
[ref, minWidth, maxWidth]
);
Comment on lines +117 to +132
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to use RequestAnimationFrame here. Otherwise this isn't performant


const handleMouseUp = useCallback(() => {
setIsHeld(false);
const newSize = ref.current?.offsetWidth ?? initialSize;
isDraggingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
onResizeEnd?.(newSize);
if (sizeStorageKey) {
localStorage.setItem(sizeStorageKey, newSize.toString());
}
}, [onResizeEnd, ref, sizeStorageKey, initialSize]);

useEffect(() => {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);

return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);

const onDoubleClick = useCallback(() => {
if (ref.current) {
ref.current.style.width = `${initialSize}px`;
}
}, [ref, initialSize]);

return {
isHeld,
size: ref.current?.offsetWidth ?? initialSize,
onMouseDown: handleMouseDown,
onDoubleClick,
};
};

export default useResizable;
2 changes: 2 additions & 0 deletions static/app/views/nav/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export const NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY = 'navigation-sidebar-is-co

export const PRIMARY_SIDEBAR_WIDTH = 74;
export const SECONDARY_SIDEBAR_WIDTH = 190;
export const SECONDARY_SIDEBAR_MIN_WIDTH = 100;
export const SECONDARY_SIDEBAR_MAX_WIDTH = 500;
87 changes: 73 additions & 14 deletions static/app/views/nav/secondary/secondarySidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {useRef} from 'react';
import styled from '@emotion/styled';

import {SECONDARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants';
import useResizable from 'sentry/utils/useResizable';
import {
SECONDARY_SIDEBAR_MAX_WIDTH,
SECONDARY_SIDEBAR_MIN_WIDTH,
SECONDARY_SIDEBAR_WIDTH,
} from 'sentry/views/nav/constants';
import {useNavContext} from 'sentry/views/nav/context';
import {
NavTourElement,
Expand All @@ -13,28 +19,51 @@ export function SecondarySidebar() {
const {setSecondaryNavEl} = useNavContext();
const {currentStepId} = useStackedNavigationTour();
const stepId = currentStepId ?? StackedNavigationTour.ISSUES;
const resizableContainerRef = useRef<HTMLDivElement>(null);
const resizeHandleRef = useRef<HTMLDivElement>(null);

const {
onMouseDown: handleStartResize,
size,
onDoubleClick,
} = useResizable({
ref: resizableContainerRef,
initialSize: SECONDARY_SIDEBAR_WIDTH,
minWidth: SECONDARY_SIDEBAR_MIN_WIDTH,
maxWidth: SECONDARY_SIDEBAR_MAX_WIDTH,
sizeStorageKey: 'secondary-sidebar-width',
});

return (
<SecondarySidebarWrapper
id={stepId}
description={STACKED_NAVIGATION_TOUR_CONTENT[stepId].description}
title={STACKED_NAVIGATION_TOUR_CONTENT[stepId].title}
>
<SecondarySidebarInner
ref={setSecondaryNavEl}
role="navigation"
aria-label="Secondary Navigation"
/>
</SecondarySidebarWrapper>
<ResizeWrapper ref={resizableContainerRef} onMouseDown={handleStartResize}>
<NavTourElement
id={stepId}
description={STACKED_NAVIGATION_TOUR_CONTENT[stepId].description}
title={STACKED_NAVIGATION_TOUR_CONTENT[stepId].title}
>
<SecondarySidebarInner
ref={setSecondaryNavEl}
role="navigation"
aria-label="Secondary Navigation"
/>
<ResizeHandle
ref={resizeHandleRef}
onMouseDown={handleStartResize}
onDoubleClick={onDoubleClick}
atMinWidth={size === SECONDARY_SIDEBAR_MIN_WIDTH}
atMaxWidth={size === SECONDARY_SIDEBAR_MAX_WIDTH}
/>
</NavTourElement>
</ResizeWrapper>
);
}

const SecondarySidebarWrapper = styled(NavTourElement)`
const ResizeWrapper = styled('div')`
position: relative;
right: 0;
border-right: 1px solid
${p => (p.theme.isChonk ? p.theme.border : p.theme.translucentGray200)};
background: ${p => (p.theme.isChonk ? p.theme.background : p.theme.surface200)};
width: ${SECONDARY_SIDEBAR_WIDTH}px;
z-index: ${p => p.theme.zIndex.sidebarPanel};
height: 100%;
`;
Expand All @@ -44,3 +73,33 @@ const SecondarySidebarInner = styled('div')`
grid-template-rows: auto 1fr auto;
height: 100%;
`;

const ResizeHandle = styled('div')<{atMaxWidth: boolean; atMinWidth: boolean}>`
position: absolute;
right: 0px;
top: 0;
bottom: 0;
width: 8px;
border-radius: 8px;
z-index: ${p => p.theme.zIndex.drawer + 2};
cursor: ${p => (p.atMinWidth ? 'e-resize' : p.atMaxWidth ? 'w-resize' : 'ew-resize')};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have useResizable consume the resize handle as a ref to apply these cursor styles. If we do that, though, then we should also just remove the onMouseDown and onDoubleClick handlers and apply that to the ref directly. This would make the hook significantly more opinionated, which is why I opted not to not have the hook handle.


&:hover,
&:active {
&::after {
background: ${p => p.theme.purple400};
}
}

&::after {
content: '';
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
opacity: 0.8;
background: transparent;
transition: background 0.25s ease 0.1s;
}
`;
Loading