-
-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
|
||
interface UseResizableOptions { | ||
/** | ||
* The ref to the element to be resized. | ||
*/ | ||
ref: RefObject<HTMLElement | null>; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]); | ||
MichaelSun48 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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; |
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, | ||
|
@@ -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%; | ||
`; | ||
|
@@ -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')}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could have |
||
|
||
&: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; | ||
} | ||
`; |
There was a problem hiding this comment.
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.