diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index dd46fd0fcca..079d34f6cff 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -621,6 +621,10 @@ width: 100%; @media screen and (min-width: 768px) { + /* + * --pane-max-width is set by JS on mount and updated on resize (debounced). + * JS calculates viewport - margin to avoid scrollbar discrepancy with 100vw. + */ width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width)); } } diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 49d2f9cfb8c..1e003a038ee 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -751,6 +751,12 @@ const Pane = React.forwardRef maxWidth) { + currentWidthRef.current = maxWidth + } + if (isKeyboard) { // Clamp keyboard delta to stay within bounds const newWidth = Math.max( diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index 4543fdfffb7..a529d0b7367 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -339,7 +339,7 @@ describe('usePaneWidth', () => { }) describe('resize listener', () => { - it('should not add resize listener when not resizable', () => { + it('should add debounced resize listener for preset widths', () => { const addEventListenerSpy = vi.spyOn(window, 'addEventListener') const refs = createMockRefs() @@ -347,36 +347,75 @@ describe('usePaneWidth', () => { usePaneWidth({ width: 'medium', minWidth: 256, - resizable: false, + resizable: true, widthStorageKey: 'test', ...refs, }), ) - expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function)) + // Adds resize listener for throttled CSS updates and debounced state sync + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) addEventListenerSpy.mockRestore() }) - it('should not add resize listener for custom widths (max is fixed)', () => { + it('should not add resize listener for custom widths (fixed max)', () => { const addEventListenerSpy = vi.spyOn(window, 'addEventListener') const refs = createMockRefs() renderHook(() => usePaneWidth({ - width: {min: '150px', default: '300px', max: '500px'}, + width: {min: '150px', default: '300px', max: '400px'}, minWidth: 256, resizable: true, - widthStorageKey: 'test', + widthStorageKey: 'test-custom', ...refs, }), ) - expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function)) + // Custom widths have fixed max - no need for resize listener + expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function), expect.anything()) addEventListenerSpy.mockRestore() }) - it('should add resize listener for preset widths when resizable', () => { - const addEventListenerSpy = vi.spyOn(window, 'addEventListener') + it('should clamp ref when viewport shrinks', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-clamp', + ...refs, + }), + ) + + // Set current width to max + const initialMax = result.current.getMaxPaneWidth() // 1280 - 511 = 769 + result.current.currentWidthRef.current = initialMax + + // Shrink viewport + vi.stubGlobal('innerWidth', 800) + + // Wrap resize + debounce in act() since it triggers startTransition state update + await act(async () => { + window.dispatchEvent(new Event('resize')) + await vi.advanceTimersByTimeAsync(150) + }) + + // getMaxPaneWidth now returns 800 - 511 = 289 + expect(result.current.getMaxPaneWidth()).toBe(289) + // ref should be clamped after resize handler fires + expect(result.current.currentWidthRef.current).toBe(289) + + vi.useRealTimers() + }) + + it('should update CSS variable immediately via throttle', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) const refs = createMockRefs() renderHook(() => @@ -384,17 +423,161 @@ describe('usePaneWidth', () => { width: 'medium', minWidth: 256, resizable: true, - widthStorageKey: 'test', + widthStorageKey: 'test-css-throttle', ...refs, }), ) - expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) - addEventListenerSpy.mockRestore() + // Initial --pane-max-width should be set on mount + expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('769px') + + // Shrink viewport + vi.stubGlobal('innerWidth', 1000) + + // Fire resize - CSS should update immediately (throttled at 16ms) + window.dispatchEvent(new Event('resize')) + + // CSS variable should be updated immediately: 1000 - 511 = 489 + expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('489px') + + vi.useRealTimers() + }) + + it('should update ARIA attributes after debounce', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-aria-debounce', + ...refs, + }), + ) + + // Initial ARIA max should be set on mount + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') + + // Shrink viewport + vi.stubGlobal('innerWidth', 900) + + // Fire resize but don't wait for debounce + 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 + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) + + // ARIA should now be updated: 900 - 511 = 389 + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389') + + vi.useRealTimers() + }) + + it('should throttle CSS updates and debounce full sync on rapid resize', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const setPropertySpy = vi.spyOn(refs.paneRef.current!.style, 'setProperty') + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-throttle-debounce', + ...refs, + }), + ) + + // Clear mount calls + setPropertySpy.mockClear() + + // Fire resize - first one updates CSS immediately + 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 + + setPropertySpy.mockClear() + + // Fire more resize events rapidly (within throttle window) + for (let i = 0; i < 3; i++) { + vi.stubGlobal('innerWidth', 1000 - i * 50) + 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 + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + + // Now ARIA and refs are synced + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389') // 900 - 511 + + vi.useRealTimers() + }) + + it('should update React state via startTransition after debounce', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-state-transition', + ...refs, + }), + ) + + // Initial maxPaneWidth state + expect(result.current.maxPaneWidth).toBe(769) + + // Shrink viewport + 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 + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) + + // State now reflects new max: 800 - 511 = 289 + expect(result.current.maxPaneWidth).toBe(289) + + vi.useRealTimers() }) it('should cleanup resize listener on unmount', () => { const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame') const refs = createMockRefs() const {unmount} = renderHook(() => @@ -410,7 +593,74 @@ describe('usePaneWidth', () => { unmount() expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + // cancelAnimationFrame called with null is fine (no pending RAF) removeEventListenerSpy.mockRestore() + cancelAnimationFrameSpy.mockRestore() + }) + + it('should not add resize listener when not resizable', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener') + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: false, + widthStorageKey: 'test-not-resizable', + ...refs, + }), + ) + + expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function)) + addEventListenerSpy.mockRestore() + }) + }) + + describe('on-demand max calculation', () => { + it('should calculate max dynamically based on current viewport', () => { + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-dynamic', + ...refs, + }), + ) + + // Initial max at 1280px: 1280 - 511 = 769 + expect(result.current.getMaxPaneWidth()).toBe(769) + + // Viewport changes (no resize event needed) + vi.stubGlobal('innerWidth', 800) + + // getMaxPaneWidth reads window.innerWidth dynamically + expect(result.current.getMaxPaneWidth()).toBe(289) + }) + + it('should return custom max regardless of viewport for custom widths', () => { + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '300px', max: '400px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test-custom', + ...refs, + }), + ) + + expect(result.current.getMaxPaneWidth()).toBe(400) + + // Viewport changes don't affect custom max + vi.stubGlobal('innerWidth', 500) + expect(result.current.getMaxPaneWidth()).toBe(400) }) }) }) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 120403e68ea..76b9519b396 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -1,4 +1,4 @@ -import React from 'react' +import React, {startTransition} from 'react' import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' import cssExports from './PageLayout.module.css' @@ -200,15 +200,26 @@ export function usePaneWidth({ getMaxPaneWidthRef.current = getMaxPaneWidth }) - // Update max pane width on mount and window resize for accurate ARIA values + // 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 useIsomorphicLayoutEffect(() => { if (!resizable) return let lastViewportWidth = window.innerWidth - const updateMax = ({forceRecalcCss = false}: {forceRecalcCss?: boolean} = {}) => { - // Track last viewport width to detect breakpoint crossings. + // 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 + + // Only call getComputedStyle if we crossed the breakpoint (expensive) const crossedBreakpoint = (lastViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT && currentViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT) || @@ -216,63 +227,82 @@ export function usePaneWidth({ currentViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT) lastViewportWidth = currentViewportWidth - // Only call getComputedStyle if we crossed the breakpoint (expensive operation) - if (forceRecalcCss || crossedBreakpoint) { + if (crossedBreakpoint) { maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) } const actualMax = getMaxPaneWidthRef.current() - setMaxPaneWidth(actualMax) - // Clamp current width if it exceeds new max (viewport shrunk) - if (currentWidthRef.current > actualMax) { + // Update CSS variable for visual clamping (may already be set by throttled update) + paneRef.current?.style.setProperty('--pane-max-width', `${actualMax}px`) + + // Track if we clamped current width + const wasClamped = currentWidthRef.current > actualMax + if (wasClamped) { currentWidthRef.current = actualMax paneRef.current?.style.setProperty('--pane-width', `${actualMax}px`) - setCurrentWidth(actualMax) } - updateAriaValues(handleRef.current, {min: minPaneWidth, max: actualMax, current: currentWidthRef.current}) + // Update ARIA via DOM - cheap, no React re-render + updateAriaValues(handleRef.current, {max: actualMax, current: currentWidthRef.current}) + + // Defer state updates so parent re-renders see accurate values + startTransition(() => { + setMaxPaneWidth(actualMax) + if (wasClamped) { + setCurrentWidth(actualMax) + } + }) } - // Initial calculation - force CSS recalc to get correct value - updateMax({forceRecalcCss: true}) + // Initial calculation on mount + maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) + const initialMax = getMaxPaneWidthRef.current() + setMaxPaneWidth(initialMax) + paneRef.current?.style.setProperty('--pane-max-width', `${initialMax}px`) + updateAriaValues(handleRef.current, {min: minPaneWidth, max: initialMax, current: currentWidthRef.current}) // For custom widths, max is fixed - no need to listen to resize if (customMaxWidth !== null) return - // Throttle resize with trailing edge: - // - Execute at most once per THROTTLE_MS during rapid resizing - // - Guarantee final event executes when resize stops - const THROTTLE_MS = 100 - let timeoutId: ReturnType | null = null - let lastExecution = 0 + // Throttle CSS updates (16ms ≈ 60fps), debounce full sync (150ms) + const THROTTLE_MS = 16 + const DEBOUNCE_MS = 150 + let rafId: number | null = null + let debounceId: ReturnType | null = null + let lastThrottleTime = 0 const handleResize = () => { const now = Date.now() - const timeSinceLastExecution = now - lastExecution - if (timeoutId !== null) { - clearTimeout(timeoutId) - timeoutId = null + // 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 + rafId = requestAnimationFrame(() => { + rafId = null + lastThrottleTime = Date.now() + updateCSSOnly() + }) } - if (timeSinceLastExecution >= THROTTLE_MS) { - lastExecution = now - updateMax() - } else { - // Schedule trailing edge execution - timeoutId = setTimeout(() => { - lastExecution = Date.now() - updateMax() - timeoutId = null - }, THROTTLE_MS - timeSinceLastExecution) + // Debounced full sync (refs, ARIA, state) when resize stops + if (debounceId !== null) { + clearTimeout(debounceId) } + debounceId = setTimeout(() => { + debounceId = null + syncAll() + }, DEBOUNCE_MS) } // eslint-disable-next-line github/prefer-observers -- Uses window resize events instead of ResizeObserver to avoid INP issues. ResizeObserver on document.documentElement fires on any content change (typing, etc), while window resize only fires on actual viewport changes. window.addEventListener('resize', handleResize) return () => { - if (timeoutId !== null) clearTimeout(timeoutId) + if (rafId !== null) cancelAnimationFrame(rafId) + if (debounceId !== null) clearTimeout(debounceId) window.removeEventListener('resize', handleResize) } }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef])