Skip to content
Closed
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
34 changes: 14 additions & 20 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {canUseDOM} from '../utils/environment'
import {useOverflow} from '../hooks/useOverflow'
import {warning} from '../utils/warning'
import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes'
import {usePaneWidth} from './usePaneWidth'

import classes from './PageLayout.module.css'
import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types'
Expand Down Expand Up @@ -172,25 +173,18 @@ const VerticalDivider: React.FC<React.PropsWithChildren<DividerProps & Draggable

const {paneRef} = React.useContext(PageLayoutContext)

const [minWidth, setMinWidth] = React.useState(0)
const [maxWidth, setMaxWidth] = React.useState(0)
const [currentWidth, setCurrentWidth] = React.useState(0)
// Use optimized hook for pane width management with RAF-based resize handling
const {minWidth, maxWidth, currentWidth} = usePaneWidth({
paneRef,
isDragging,
isKeyboardDrag,
})

// Track current width locally for synchronous keyboard dragging
const currentWidthRef = React.useRef(currentWidth)
React.useEffect(() => {
if (paneRef.current !== null) {
const paneStyles = getComputedStyle(paneRef.current as Element)
const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff')
const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width')
const paneWidth = paneRef.current.getBoundingClientRect().width
const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0])
const minPaneWidth = Number(minWidthPixels.split('px')[0])
const viewportWidth = window.innerWidth
const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth
setMinWidth(minPaneWidth)
setMaxWidth(maxPaneWidth)
setCurrentWidth(paneWidth || 0)
}
}, [paneRef, isKeyboardDrag, isDragging])
currentWidthRef.current = currentWidth
}, [currentWidth])

React.useEffect(() => {
stableOnDrag.current = onDrag
Expand All @@ -215,14 +209,14 @@ const VerticalDivider: React.FC<React.PropsWithChildren<DividerProps & Draggable
function handleKeyDrag(event: KeyboardEvent) {
let delta = 0
// https://github.com/github/accessibility/issues/5101#issuecomment-1822870655
if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && currentWidth > minWidth) {
if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && currentWidthRef.current > minWidth) {
delta = -3
} else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) {
} else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidthRef.current < maxWidth) {
delta = 3
} else {
return
}
setCurrentWidth(currentWidth + delta)
currentWidthRef.current = currentWidthRef.current + delta
stableOnDrag.current?.(delta, true)
event.preventDefault()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ exports[`PageLayout > renders condensed layout 1`] = `
/>
<div
class="prc-PageLayout-Pane-g6fEI"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px;"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px; --pane-max-width-visual: 321px;"
>
Pane
</div>
Expand Down Expand Up @@ -149,7 +149,7 @@ exports[`PageLayout > renders default layout 1`] = `
/>
<div
class="prc-PageLayout-Pane-g6fEI"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px;"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px; --pane-max-width-visual: 321px;"
>
Pane
</div>
Expand Down Expand Up @@ -243,7 +243,7 @@ exports[`PageLayout > renders pane in different position when narrow 1`] = `
/>
<div
class="prc-PageLayout-Pane-g6fEI"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px;"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px; --pane-max-width-visual: 321px;"
>
Pane
</div>
Expand Down Expand Up @@ -337,7 +337,7 @@ exports[`PageLayout > renders with dividers 1`] = `
/>
<div
class="prc-PageLayout-Pane-g6fEI"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px;"
style="--spacing: var(--spacing-none); --pane-min-width: 256px; --pane-max-width: calc(100vw - var(--pane-max-width-diff)); --pane-width-size: var(--pane-width-medium); --pane-width: 296px; --pane-max-width-visual: 321px;"
>
Pane
</div>
Expand Down
129 changes: 129 additions & 0 deletions packages/react/src/PageLayout/usePaneWidth.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {render, waitFor} from '@testing-library/react'
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {usePaneWidth} from './usePaneWidth'
import {useRef} from 'react'

describe('usePaneWidth', () => {
let mockPane: HTMLDivElement

beforeEach(() => {
// Create a mock pane element
mockPane = document.createElement('div')
mockPane.style.setProperty('--pane-max-width-diff', '300px')
mockPane.style.setProperty('--pane-min-width', '256px')

// Mock getBoundingClientRect
vi.spyOn(mockPane, 'getBoundingClientRect').mockReturnValue({
width: 320,
height: 600,
top: 0,
left: 0,
right: 320,
bottom: 600,
x: 0,
y: 0,
toJSON: () => ({}),
})

// Mock window.innerWidth
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1024,
})

document.body.appendChild(mockPane)
})

afterEach(() => {
document.body.removeChild(mockPane)
vi.restoreAllMocks()
})

it('should calculate initial pane width metrics', async () => {
const TestComponent = () => {
const paneRef = useRef<HTMLDivElement>(mockPane)
const {minWidth, maxWidth, currentWidth} = usePaneWidth({paneRef})

return (
<div>
<span data-testid="min">{minWidth}</span>
<span data-testid="max">{maxWidth}</span>
<span data-testid="current">{currentWidth}</span>
</div>
)
}

const {getByTestId} = render(<TestComponent />)

await waitFor(() => {
expect(getByTestId('min').textContent).toBe('256')
expect(getByTestId('max').textContent).toBe('724') // 1024 - 300
expect(getByTestId('current').textContent).toBe('320')
})
})

it('should use requestAnimationFrame for window resize', async () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')

const TestComponent = () => {
const paneRef = useRef<HTMLDivElement>(mockPane)
usePaneWidth({paneRef})
return <div>Test</div>
}

render(<TestComponent />)

// Trigger resize event
window.dispatchEvent(new Event('resize'))

// Verify requestAnimationFrame was called
await waitFor(() => {
expect(rafSpy).toHaveBeenCalled()
})

rafSpy.mockRestore()
})

it('should throttle multiple resize events to one requestAnimationFrame', async () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')

const TestComponent = () => {
const paneRef = useRef<HTMLDivElement>(mockPane)
usePaneWidth({paneRef})
return <div>Test</div>
}

render(<TestComponent />)

// Dispatch multiple resize events in quick succession
window.dispatchEvent(new Event('resize'))
window.dispatchEvent(new Event('resize'))
window.dispatchEvent(new Event('resize'))

// Should only schedule one requestAnimationFrame
await waitFor(() => {
expect(rafSpy).toHaveBeenCalledTimes(1)
})

rafSpy.mockRestore()
})

it('should clean up event listeners on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')

const TestComponent = () => {
const paneRef = useRef<HTMLDivElement>(mockPane)
usePaneWidth({paneRef})
return <div>Test</div>
}

const {unmount} = render(<TestComponent />)

unmount()

expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))

removeEventListenerSpy.mockRestore()
})
})
130 changes: 130 additions & 0 deletions packages/react/src/PageLayout/usePaneWidth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React from 'react'

export interface PaneWidthMetrics {
minWidth: number
maxWidth: number
currentWidth: number
}

export interface UsePaneWidthOptions {
paneRef: React.RefObject<HTMLDivElement>
isDragging?: boolean
isKeyboardDrag?: boolean
}

/**
* Hook to manage pane width calculations with optimized window resize handling.
* Uses requestAnimationFrame for smooth visual updates aligned with browser paint cycle.
*/
export function usePaneWidth({paneRef, isDragging = false, isKeyboardDrag = false}: UsePaneWidthOptions) {
const [minWidth, setMinWidth] = React.useState(0)
const [maxWidth, setMaxWidth] = React.useState(0)
const [currentWidth, setCurrentWidth] = React.useState(0)

// Refs to track pending updates
const rafIdRef = React.useRef<number | null>(null)
const idleIdRef = React.useRef<number | null>(null)

const updateMetrics = React.useCallback(() => {
if (paneRef.current === null) return

const paneStyles = getComputedStyle(paneRef.current as Element)
const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff')
const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width')
const paneWidth = paneRef.current.getBoundingClientRect().width
const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0])
const minPaneWidth = Number(minWidthPixels.split('px')[0])
const viewportWidth = window.innerWidth
const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth

// Update CSS variable immediately for visual clamping - this prevents overflow during resize
paneRef.current.style.setProperty('--pane-max-width-visual', `${maxPaneWidth}px`)

return {
minPaneWidth,
maxPaneWidth,
paneWidth: paneWidth || 0,
}
}, [paneRef])

const updateStateAndAria = React.useCallback(
(metrics: {minPaneWidth: number; maxPaneWidth: number; paneWidth: number}) => {
// Defer state updates to avoid blocking during active resize
// Use requestIdleCallback with fallback to setTimeout
const hasIdleCallback = typeof window !== 'undefined' && 'requestIdleCallback' in window
const idleCallback = hasIdleCallback
? window.requestIdleCallback!
: (cb: IdleRequestCallback) => setTimeout(cb, 1) as unknown as number

if (idleIdRef.current !== null) {
if (hasIdleCallback) {
window.cancelIdleCallback!(idleIdRef.current)
} else {
clearTimeout(idleIdRef.current)
}
}

idleIdRef.current = idleCallback(() => {
setMinWidth(metrics.minPaneWidth)
setMaxWidth(metrics.maxPaneWidth)
setCurrentWidth(metrics.paneWidth)
idleIdRef.current = null
})
},
[],
)

// Initial setup and dragging state changes
React.useEffect(() => {
const metrics = updateMetrics()
if (metrics) {
// For initial mount and dragging state changes, update immediately
setMinWidth(metrics.minPaneWidth)
setMaxWidth(metrics.maxPaneWidth)
setCurrentWidth(metrics.paneWidth)
}
}, [paneRef, isKeyboardDrag, isDragging, updateMetrics])

// Window resize handler with requestAnimationFrame
React.useEffect(() => {
const handleResize = () => {
// Only schedule one animation frame at a time
if (rafIdRef.current !== null) return

rafIdRef.current = requestAnimationFrame(() => {
const metrics = updateMetrics()
if (metrics) {
// Defer non-critical state updates
updateStateAndAria(metrics)
}
rafIdRef.current = null
})
}

// eslint-disable-next-line github/prefer-observers
window.addEventListener('resize', handleResize)

return () => {
window.removeEventListener('resize', handleResize)

// Cleanup pending animation frames
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}

// Cleanup pending idle callbacks
const hasIdleCallback = typeof window !== 'undefined' && 'requestIdleCallback' in window
if (idleIdRef.current !== null) {
if (hasIdleCallback) {
window.cancelIdleCallback!(idleIdRef.current)
} else {
clearTimeout(idleIdRef.current)
}
idleIdRef.current = null
}
}
}, [updateMetrics, updateStateAndAria])

return {minWidth, maxWidth, currentWidth}
}