) => {
- if (
- event.key === 'ArrowLeft' ||
- event.key === 'ArrowRight' ||
- event.key === 'ArrowUp' ||
- event.key === 'ArrowDown'
- ) {
- event.preventDefault()
- setDragging(false)
- stableOnDragEnd.current()
- }
+ if (!isArrowKey(event.key)) return
+ event.preventDefault()
+ endDragging()
+ stableOnDragEnd.current()
},
- [setDragging],
+ [endDragging],
)
+ // Cleanup rAF on unmount to prevent stale callbacks
+ React.useEffect(() => {
+ return () => {
+ if (rafIdRef.current !== null) {
+ cancelAnimationFrame(rafIdRef.current)
+ rafIdRef.current = null
+ }
+ }
+ }, [])
+
return (
= ({
onDoubleClick={onDoubleClick}
/>
)
-}
+})
// ----------------------------------------------------------------------------
// PageLayout.Header
@@ -461,7 +497,6 @@ const Header: FCWithSlotMarker> =
)
}
-
Header.displayName = 'PageLayout.Header'
// ----------------------------------------------------------------------------
@@ -511,9 +546,11 @@ const Content: FCWithSlotMarker>
style,
}) => {
const Component = as
+ const {contentWrapperRef} = React.useContext(PageLayoutContext)
return (
>
)
}
-
Content.displayName = 'PageLayout.Content'
// ----------------------------------------------------------------------------
@@ -637,7 +673,7 @@ const Pane = React.forwardRef(null)
@@ -657,6 +693,7 @@ const Pane = React.forwardRef> =
)
}
-
Footer.displayName = 'PageLayout.Footer'
// ----------------------------------------------------------------------------
diff --git a/packages/react/src/PageLayout/paneUtils.ts b/packages/react/src/PageLayout/paneUtils.ts
new file mode 100644
index 00000000000..a33f8bbc010
--- /dev/null
+++ b/packages/react/src/PageLayout/paneUtils.ts
@@ -0,0 +1,31 @@
+type DraggingStylesParams = {
+ handle: HTMLElement | null
+ pane: HTMLElement | null
+ contentWrapper: HTMLElement | null
+}
+
+const DATA_DRAGGING_ATTR = 'data-dragging'
+
+/** Apply visual feedback and performance optimizations during drag */
+export function setDraggingStyles({handle, pane, contentWrapper}: DraggingStylesParams) {
+ // Handle visual feedback (must be inline for instant response)
+ // Use CSS variable to control ::before pseudo-element background color.
+ // This avoids cascade conflicts between inline styles and pseudo-element backgrounds.
+ handle?.style.setProperty('--draggable-handle--bg-color', 'var(--bgColor-accent-emphasis)')
+ handle?.style.setProperty('--draggable-handle--drag-opacity', '1')
+ handle?.style.setProperty('--draggable-handle--transition', 'none')
+
+ // Set attribute for CSS containment (O(1) direct selector, not descendant)
+ pane?.setAttribute(DATA_DRAGGING_ATTR, 'true')
+ contentWrapper?.setAttribute(DATA_DRAGGING_ATTR, 'true')
+}
+
+/** Remove drag styles and restore normal state */
+export function removeDraggingStyles({handle, pane, contentWrapper}: DraggingStylesParams) {
+ handle?.style.removeProperty('--draggable-handle--bg-color')
+ handle?.style.removeProperty('--draggable-handle--drag-opacity')
+ handle?.style.removeProperty('--draggable-handle--transition')
+
+ pane?.removeAttribute(DATA_DRAGGING_ATTR)
+ contentWrapper?.removeAttribute(DATA_DRAGGING_ATTR)
+}
diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts
index a529d0b7367..570fb6c47ed 100644
--- a/packages/react/src/PageLayout/usePaneWidth.test.ts
+++ b/packages/react/src/PageLayout/usePaneWidth.test.ts
@@ -17,6 +17,7 @@ import {
const createMockRefs = () => ({
paneRef: {current: document.createElement('div')} as React.RefObject,
handleRef: {current: document.createElement('div')} as React.RefObject,
+ contentWrapperRef: {current: document.createElement('div')} as React.RefObject,
})
describe('usePaneWidth', () => {
@@ -399,10 +400,10 @@ describe('usePaneWidth', () => {
// Shrink viewport
vi.stubGlobal('innerWidth', 800)
- // Wrap resize + debounce in act() since it triggers startTransition state update
+ // Wrap resize + throttle in act() since it triggers startTransition state update
await act(async () => {
window.dispatchEvent(new Event('resize'))
- await vi.advanceTimersByTimeAsync(150)
+ await vi.runAllTimersAsync()
})
// getMaxPaneWidth now returns 800 - 511 = 289
@@ -413,7 +414,7 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})
- it('should update CSS variable immediately via throttle', async () => {
+ it('should throttle CSS variable update', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
@@ -434,16 +435,22 @@ describe('usePaneWidth', () => {
// Shrink viewport
vi.stubGlobal('innerWidth', 1000)
- // Fire resize - CSS should update immediately (throttled at 16ms)
+ // Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed)
window.dispatchEvent(new Event('resize'))
- // CSS variable should be updated immediately: 1000 - 511 = 489
+ // Since Date.now() starts at 0 and lastUpdateTime is 0, first update should happen immediately
+ // but it's in rAF, so we need to advance through rAF
+ await act(async () => {
+ await vi.runAllTimersAsync()
+ })
+
+ // CSS variable should now be updated: 1000 - 511 = 489
expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('489px')
vi.useRealTimers()
})
- it('should update ARIA attributes after debounce', async () => {
+ it('should update ARIA attributes after throttle', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
@@ -453,7 +460,7 @@ describe('usePaneWidth', () => {
width: 'medium',
minWidth: 256,
resizable: true,
- widthStorageKey: 'test-aria-debounce',
+ widthStorageKey: 'test-aria-throttle',
...refs,
}),
)
@@ -464,16 +471,12 @@ describe('usePaneWidth', () => {
// Shrink viewport
vi.stubGlobal('innerWidth', 900)
- // Fire resize but don't wait for debounce
+ // Fire resize - with throttle, update happens via rAF
window.dispatchEvent(new Event('resize'))
- await vi.advanceTimersByTimeAsync(50)
- // ARIA should NOT be updated yet
- expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769')
-
- // Wait for debounce
+ // Wait for rAF to complete
await act(async () => {
- await vi.advanceTimersByTimeAsync(100)
+ await vi.runAllTimersAsync()
})
// ARIA should now be updated: 900 - 511 = 389
@@ -482,7 +485,7 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})
- it('should throttle CSS updates and debounce full sync on rapid resize', async () => {
+ it('should throttle full sync on rapid resize', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
@@ -494,7 +497,7 @@ describe('usePaneWidth', () => {
width: 'medium',
minWidth: 256,
resizable: true,
- widthStorageKey: 'test-throttle-debounce',
+ widthStorageKey: 'test-throttle',
...refs,
}),
)
@@ -502,13 +505,19 @@ describe('usePaneWidth', () => {
// Clear mount calls
setPropertySpy.mockClear()
- // Fire resize - first one updates CSS immediately
+ // Fire resize events rapidly
vi.stubGlobal('innerWidth', 1100)
window.dispatchEvent(new Event('resize'))
- // CSS should update immediately (first call, throttle allows)
- expect(setPropertySpy).toHaveBeenCalledWith('--pane-max-width', '589px') // 1100 - 511
+ // With throttle, CSS should update immediately or via rAF
+ await act(async () => {
+ await vi.runAllTimersAsync()
+ })
+
+ // First update should have happened: 1100 - 511 = 589
+ expect(setPropertySpy).toHaveBeenCalledWith('--pane-max-width', '589px')
+ // Clear for next test
setPropertySpy.mockClear()
// Fire more resize events rapidly (within throttle window)
@@ -517,28 +526,19 @@ describe('usePaneWidth', () => {
window.dispatchEvent(new Event('resize'))
}
- // Throttle limits calls - may have scheduled RAF but not executed yet
- // Advance past throttle window to let RAF execute
- await vi.advanceTimersByTimeAsync(20)
-
- // Should have at least one more CSS update from RAF
- expect(setPropertySpy).toHaveBeenCalled()
-
- // But ARIA should not be updated yet (debounced)
- expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') // Still initial
-
- // Wait for debounce to complete
+ // Should schedule via rAF
await act(async () => {
- await vi.advanceTimersByTimeAsync(150)
+ await vi.runAllTimersAsync()
})
- // Now ARIA and refs are synced
- expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389') // 900 - 511
+ // Now CSS and ARIA should be synced with final viewport value (900)
+ expect(setPropertySpy).toHaveBeenCalledWith('--pane-max-width', '389px') // 900 - 511
+ expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389')
vi.useRealTimers()
})
- it('should update React state via startTransition after debounce', async () => {
+ it('should update React state via startTransition after throttle', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
@@ -560,13 +560,9 @@ describe('usePaneWidth', () => {
vi.stubGlobal('innerWidth', 800)
window.dispatchEvent(new Event('resize'))
- // Before debounce completes, state unchanged
- await vi.advanceTimersByTimeAsync(50)
- expect(result.current.maxPaneWidth).toBe(769)
-
- // After debounce, state updated via startTransition
+ // After throttle (via rAF), state updated via startTransition
await act(async () => {
- await vi.advanceTimersByTimeAsync(100)
+ await vi.runAllTimersAsync()
})
// State now reflects new max: 800 - 511 = 289
@@ -615,6 +611,86 @@ describe('usePaneWidth', () => {
expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function))
addEventListenerSpy.mockRestore()
})
+
+ it('should apply and remove containment attributes during resize', async () => {
+ vi.useFakeTimers()
+ vi.stubGlobal('innerWidth', 1280)
+ const refs = createMockRefs()
+
+ renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-containment',
+ ...refs,
+ }),
+ )
+
+ // Initially no data-dragging attribute
+ expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(false)
+ expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(false)
+
+ // Fire resize
+ vi.stubGlobal('innerWidth', 1000)
+ window.dispatchEvent(new Event('resize'))
+
+ // Attribute should be applied immediately on first resize
+ expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true)
+ expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(true)
+
+ // Fire another resize event immediately (simulating continuous resize)
+ vi.stubGlobal('innerWidth', 900)
+ window.dispatchEvent(new Event('resize'))
+
+ // Attribute should still be present (containment stays on during continuous resize)
+ expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true)
+ expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(true)
+
+ // Wait for the debounce timeout (150ms) to complete after resize stops
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(150)
+ })
+
+ // Attribute should be removed after debounce completes
+ expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(false)
+ expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(false)
+
+ vi.useRealTimers()
+ })
+
+ it('should cleanup containment attributes on unmount during resize', async () => {
+ vi.useFakeTimers()
+ vi.stubGlobal('innerWidth', 1280)
+ const refs = createMockRefs()
+
+ const {unmount} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-cleanup-containment',
+ ...refs,
+ }),
+ )
+
+ // Fire resize
+ vi.stubGlobal('innerWidth', 1000)
+ window.dispatchEvent(new Event('resize'))
+
+ // Attribute should be applied
+ expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true)
+ expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(true)
+
+ // Unmount immediately (before debounce timer fires)
+ unmount()
+
+ // Attribute should be cleaned up on unmount regardless of timing
+ expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(false)
+ expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(false)
+
+ vi.useRealTimers()
+ })
})
describe('on-demand max calculation', () => {
diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts
index 2449901b0a0..4244e94d1e9 100644
--- a/packages/react/src/PageLayout/usePaneWidth.ts
+++ b/packages/react/src/PageLayout/usePaneWidth.ts
@@ -23,6 +23,7 @@ export type UsePaneWidthOptions = {
widthStorageKey: string
paneRef: React.RefObject
handleRef: React.RefObject
+ contentWrapperRef: React.RefObject
}
export type UsePaneWidthResult = {
@@ -77,7 +78,7 @@ export const isCustomWidthOptions = (width: PaneWidth | CustomWidthOptions): wid
}
export const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => {
- return ['small', 'medium', 'large'].includes(width as PaneWidth)
+ return width === 'small' || width === 'medium' || width === 'large'
}
export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => {
@@ -130,6 +131,7 @@ export function usePaneWidth({
widthStorageKey,
paneRef,
handleRef,
+ contentWrapperRef,
}: UsePaneWidthOptions): UsePaneWidthResult {
// Derive constraints from width configuration
const isCustomWidth = isCustomWidthOptions(width)
@@ -187,7 +189,10 @@ export function usePaneWidth({
const saveWidth = React.useCallback(
(value: number) => {
currentWidthRef.current = value
- setCurrentWidth(value)
+ // Visual update already done via inline styles - React state sync is non-urgent
+ startTransition(() => {
+ setCurrentWidth(value)
+ })
try {
localStorage.setItem(widthStorageKey, value.toString())
} catch {
@@ -205,20 +210,12 @@ export function usePaneWidth({
})
// Update CSS variable, refs, and ARIA on mount and window resize.
- // Strategy:
- // 1. Throttled (16ms): Update --pane-max-width CSS variable for immediate visual clamp
- // 2. Debounced (150ms): Sync refs, ARIA, and React state when resize stops
+ // Strategy: Only sync when resize stops (debounced) to avoid layout thrashing on large DOMs
useIsomorphicLayoutEffect(() => {
if (!resizable) return
let lastViewportWidth = window.innerWidth
- // Quick CSS-only update for immediate visual feedback (throttled)
- const updateCSSOnly = () => {
- const actualMax = getMaxPaneWidthRef.current()
- paneRef.current?.style.setProperty('--pane-max-width', `${actualMax}px`)
- }
-
// Full sync of refs, ARIA, and state (debounced, runs when resize stops)
const syncAll = () => {
const currentViewportWidth = window.innerWidth
@@ -269,36 +266,53 @@ export function usePaneWidth({
// For custom widths, max is fixed - no need to listen to resize
if (customMaxWidth !== null) return
- // Throttle CSS updates (16ms ≈ 60fps), debounce full sync (150ms)
- const THROTTLE_MS = 16
- const DEBOUNCE_MS = 150
+ // Throttle approach for window resize - provides immediate visual feedback for small DOMs
+ // while still limiting update frequency
+ const THROTTLE_MS = 16 // ~60fps
+ const DEBOUNCE_MS = 150 // Delay before removing containment after resize stops
+ let lastUpdateTime = 0
+ let pendingUpdate = false
let rafId: number | null = null
let debounceId: ReturnType | null = null
- let lastThrottleTime = 0
+ let isResizing = false
+
+ const startResizeOptimizations = () => {
+ if (isResizing) return
+ isResizing = true
+ paneRef.current?.setAttribute('data-dragging', 'true')
+ contentWrapperRef.current?.setAttribute('data-dragging', 'true')
+ }
+
+ const endResizeOptimizations = () => {
+ if (!isResizing) return
+ isResizing = false
+ paneRef.current?.removeAttribute('data-dragging')
+ contentWrapperRef.current?.removeAttribute('data-dragging')
+ }
const handleResize = () => {
- const now = Date.now()
+ // Apply containment on first resize event (stays applied until resize stops)
+ startResizeOptimizations()
- // Throttled CSS update for immediate visual feedback
- if (now - lastThrottleTime >= THROTTLE_MS) {
- lastThrottleTime = now
- updateCSSOnly()
- } else if (rafId === null) {
- // Schedule next frame if we're within throttle window
+ const now = Date.now()
+ if (now - lastUpdateTime >= THROTTLE_MS) {
+ lastUpdateTime = now
+ syncAll()
+ } else if (!pendingUpdate) {
+ pendingUpdate = true
rafId = requestAnimationFrame(() => {
+ pendingUpdate = false
rafId = null
- lastThrottleTime = Date.now()
- updateCSSOnly()
+ lastUpdateTime = Date.now()
+ syncAll()
})
}
- // Debounced full sync (refs, ARIA, state) when resize stops
- if (debounceId !== null) {
- clearTimeout(debounceId)
- }
+ // Debounce the cleanup — remove containment after resize stops
+ if (debounceId !== null) clearTimeout(debounceId)
debounceId = setTimeout(() => {
debounceId = null
- syncAll()
+ endResizeOptimizations()
}, DEBOUNCE_MS)
}
@@ -307,9 +321,10 @@ export function usePaneWidth({
return () => {
if (rafId !== null) cancelAnimationFrame(rafId)
if (debounceId !== null) clearTimeout(debounceId)
+ endResizeOptimizations()
window.removeEventListener('resize', handleResize)
}
- }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef])
+ }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef, contentWrapperRef])
return {
currentWidth,