Skip to content

Commit 2f29207

Browse files
committed
fix(sidebar): restore shared collapse animation
1 parent 0b5e01b commit 2f29207

3 files changed

Lines changed: 87 additions & 4 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,9 @@ function isFullscreenPath(pathname: string | null): boolean {
160160
* zero width, revealing the route content. Because this component lives in the
161161
* layout it persists across navigations, so the rail never re-mounts.
162162
*
163-
* Nothing here animates: collapse, expand, and the fullscreen swap all apply in
164-
* one frame. The rail and the pane meet on a single hairline divider with no
165-
* gutter, radius, or shift between states.
163+
* The docked rail and content pane share one width transition. Drag-resizing,
164+
* hydration, and reduced motion bypass it; the floating peek retains its own
165+
* enter/exit animation.
166166
*
167167
* Because the chrome observes every pathname transition, it records the page a
168168
* fullscreen route was launched from into {@link useFullscreenOriginStore}. The
@@ -331,6 +331,9 @@ export function WorkspaceChrome({
331331
ref={cardRef}
332332
className={cn(
333333
'sidebar-shell-outer shrink-0 overflow-hidden',
334+
hasHydrated &&
335+
!isPeekActive &&
336+
'transition-[width] duration-175 ease-[cubic-bezier(0.25,0.1,0.25,1)] data-[resizing]:transition-none motion-reduce:transition-none',
334337
isPeekActive
335338
? isPeekOpen
336339
? PEEK_CARD_ENTER
@@ -345,7 +348,12 @@ export function WorkspaceChrome({
345348
aria-hidden={isFullscreen || (isPeekActive && !isPeekOpen) || undefined}
346349
suppressHydrationWarning
347350
>
348-
<div className='sidebar-shell-inner h-full w-[var(--sidebar-width)] shrink-0'>
351+
<div
352+
className={cn(
353+
'sidebar-shell-inner h-full shrink-0 [&_.sidebar-container]:w-full!',
354+
isPeekActive ? 'w-[var(--sidebar-width)]' : 'w-full'
355+
)}
356+
>
349357
<SidebarChromeProvider isCollapsed={isCollapsed} isPeeking={isPeekActive}>
350358
{sidebar}
351359
</SidebarChromeProvider>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize'
8+
import { useSidebarStore } from '@/stores/sidebar/store'
9+
10+
let container: HTMLDivElement
11+
let root: Root
12+
13+
function ResizeHandle() {
14+
const { handlePointerDown } = useSidebarResize()
15+
return <div role='separator' onPointerDown={handlePointerDown} />
16+
}
17+
18+
beforeEach(() => {
19+
vi.useFakeTimers()
20+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
21+
useSidebarStore.setState({ isCollapsed: false, sidebarWidth: 256 })
22+
container = document.createElement('div')
23+
container.className = 'sidebar-shell-outer'
24+
document.body.appendChild(container)
25+
root = createRoot(container)
26+
act(() => root.render(<ResizeHandle />))
27+
})
28+
29+
afterEach(() => {
30+
act(() => root.unmount())
31+
container.remove()
32+
vi.useRealTimers()
33+
})
34+
35+
function startResize() {
36+
const handle = container.querySelector('[role="separator"]')!
37+
act(() => handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })))
38+
expect(container.hasAttribute('data-resizing')).toBe(true)
39+
}
40+
41+
describe('sidebar resize lifecycle', () => {
42+
it.each(['pointerup', 'pointercancel', 'blur', 'unmount'])(
43+
'restores animation and persists the final width after %s',
44+
(event) => {
45+
startResize()
46+
act(() => {
47+
document.dispatchEvent(new MouseEvent('pointermove', { clientX: 280 }))
48+
vi.advanceTimersByTime(20)
49+
})
50+
expect(container.style.getPropertyValue('--sidebar-width')).toBe('280px')
51+
expect(useSidebarStore.getState().sidebarWidth).toBe(256)
52+
53+
act(() => {
54+
if (event === 'unmount') root.render(null)
55+
else if (event === 'blur') window.dispatchEvent(new Event(event))
56+
else document.dispatchEvent(new Event(event))
57+
})
58+
59+
expect(container.hasAttribute('data-resizing')).toBe(false)
60+
expect(container.style.getPropertyValue('--sidebar-width')).toBe('')
61+
expect(useSidebarStore.getState().sidebarWidth).toBe(280)
62+
expect(document.body.style.cursor).toBe('')
63+
expect(document.body.style.userSelect).toBe('')
64+
}
65+
)
66+
67+
it('restores animation when released without moving', () => {
68+
startResize()
69+
act(() => document.dispatchEvent(new Event('pointerup')))
70+
expect(container.hasAttribute('data-resizing')).toBe(false)
71+
expect(useSidebarStore.getState().sidebarWidth).toBe(256)
72+
})
73+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function useSidebarResize() {
4040
const pointerId = e.pointerId
4141
const shell = document.querySelector<HTMLElement>('.sidebar-shell-outer')
4242
const target = shell ?? document.documentElement
43+
target.setAttribute('data-resizing', '')
4344
document.body.style.cursor = 'ew-resize'
4445
document.body.style.userSelect = 'none'
4546
handle.setPointerCapture?.(pointerId)
@@ -79,6 +80,7 @@ export function useSidebarResize() {
7980
setSidebarWidth(lastWidth)
8081
if (target !== document.documentElement) target.style.removeProperty('--sidebar-width')
8182
}
83+
target.removeAttribute('data-resizing')
8284
}
8385

8486
teardownRef.current = endDrag

0 commit comments

Comments
 (0)