@@ -913,7 +746,7 @@ const Pane = React.forwardRef
{
const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta
const maxWidth = getMaxPaneWidth()
@@ -950,7 +783,7 @@ const Pane = React.forwardRef {
- const resetWidth = getDefaultPaneWidth(width)
+ const resetWidth = getDefaultWidth()
if (paneRef.current) {
paneRef.current.style.setProperty('--pane-width', `${resetWidth}px`)
currentWidthRef.current = resetWidth
diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts
new file mode 100644
index 00000000000..4543fdfffb7
--- /dev/null
+++ b/packages/react/src/PageLayout/usePaneWidth.test.ts
@@ -0,0 +1,521 @@
+import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
+import {renderHook, act} from '@testing-library/react'
+import {
+ usePaneWidth,
+ isCustomWidthOptions,
+ isPaneWidth,
+ getDefaultPaneWidth,
+ getPaneMaxWidthDiff,
+ updateAriaValues,
+ defaultPaneWidth,
+ DEFAULT_MAX_WIDTH_DIFF,
+ SSR_DEFAULT_MAX_WIDTH,
+ ARROW_KEY_STEP,
+} from './usePaneWidth'
+
+// Mock refs for hook testing
+const createMockRefs = () => ({
+ paneRef: {current: document.createElement('div')} as React.RefObject,
+ handleRef: {current: document.createElement('div')} as React.RefObject,
+})
+
+describe('usePaneWidth', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ vi.stubGlobal('innerWidth', 1280)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ describe('initialization', () => {
+ it('should initialize with default width for preset size', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-pane',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.currentWidth).toBe(defaultPaneWidth.medium)
+ })
+
+ it('should initialize with custom width default', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: {min: '200px', default: '350px', max: '500px'},
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-pane',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.currentWidth).toBe(350)
+ })
+
+ it('should restore width from localStorage on mount', () => {
+ localStorage.setItem('test-pane', '400')
+ const refs = createMockRefs()
+
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-pane',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.currentWidth).toBe(400)
+ })
+
+ it('should not restore from localStorage when not resizable', () => {
+ localStorage.setItem('test-pane', '400')
+ const refs = createMockRefs()
+
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: false,
+ widthStorageKey: 'test-pane',
+ ...refs,
+ }),
+ )
+
+ // Should use default, not localStorage value
+ expect(result.current.currentWidth).toBe(defaultPaneWidth.medium)
+ })
+
+ it('should ignore invalid localStorage values', () => {
+ localStorage.setItem('test-pane', 'invalid')
+ const refs = createMockRefs()
+
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-pane',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.currentWidth).toBe(defaultPaneWidth.medium)
+ })
+
+ it('should ignore zero or negative localStorage values', () => {
+ localStorage.setItem('test-pane', '0')
+ const refs = createMockRefs()
+
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-pane',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.currentWidth).toBe(defaultPaneWidth.medium)
+ })
+ })
+
+ describe('saveWidth', () => {
+ it('should update state and localStorage', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-save',
+ ...refs,
+ }),
+ )
+
+ act(() => {
+ result.current.saveWidth(450)
+ })
+
+ expect(result.current.currentWidth).toBe(450)
+ expect(result.current.currentWidthRef.current).toBe(450)
+ expect(localStorage.getItem('test-save')).toBe('450')
+ })
+
+ it('should handle localStorage write errors gracefully', () => {
+ const refs = createMockRefs()
+
+ // Mock localStorage.setItem to throw
+ const originalSetItem = localStorage.setItem
+ localStorage.setItem = vi.fn(() => {
+ throw new Error('QuotaExceeded')
+ })
+
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test-save',
+ ...refs,
+ }),
+ )
+
+ // Should not throw
+ act(() => {
+ result.current.saveWidth(450)
+ })
+
+ // State should still update
+ expect(result.current.currentWidth).toBe(450)
+
+ localStorage.setItem = originalSetItem
+ })
+ })
+
+ describe('minPaneWidth', () => {
+ it('should use minWidth prop for preset widths', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 200,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.minPaneWidth).toBe(200)
+ })
+
+ it('should use width.min for custom widths', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: {min: '150px', default: '300px', max: '500px'},
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.minPaneWidth).toBe(150)
+ })
+ })
+
+ describe('maxPaneWidth', () => {
+ it('should use SSR default initially for preset widths', () => {
+ const refs = createMockRefs()
+ // We need to test the initial state before effects run
+ // Since we're in browser environment, the effect runs immediately
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: false, // Disable to prevent effect from updating
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.maxPaneWidth).toBe(SSR_DEFAULT_MAX_WIDTH)
+ })
+
+ it('should use custom max for custom widths', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: {min: '150px', default: '300px', max: '500px'},
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.maxPaneWidth).toBe(500)
+ })
+ })
+
+ describe('getMaxPaneWidth', () => {
+ it('should return custom max for custom widths regardless of viewport', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: {min: '150px', default: '300px', max: '400px'},
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.getMaxPaneWidth()).toBe(400)
+
+ // Even if viewport changes, custom max is fixed
+ vi.stubGlobal('innerWidth', 500)
+ expect(result.current.getMaxPaneWidth()).toBe(400)
+ })
+
+ it('should calculate max based on viewport for preset widths', () => {
+ const refs = createMockRefs()
+ vi.stubGlobal('innerWidth', 1280)
+
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ // viewport (1280) - DEFAULT_MAX_WIDTH_DIFF (511) = 769
+ expect(result.current.getMaxPaneWidth()).toBe(769)
+ })
+
+ it('should return minPaneWidth when viewport is too small', () => {
+ const refs = createMockRefs()
+ vi.stubGlobal('innerWidth', 300) // Very small viewport
+
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ // 300 - 511 = -211, so Math.max(256, -211) = 256
+ expect(result.current.getMaxPaneWidth()).toBe(256)
+ })
+ })
+
+ describe('getDefaultWidth', () => {
+ it('should return default for preset width', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: 'large',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.getDefaultWidth()).toBe(defaultPaneWidth.large)
+ })
+
+ it('should return custom default for custom widths', () => {
+ const refs = createMockRefs()
+ const {result} = renderHook(() =>
+ usePaneWidth({
+ width: {min: '150px', default: '275px', max: '500px'},
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(result.current.getDefaultWidth()).toBe(275)
+ })
+ })
+
+ describe('resize listener', () => {
+ 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',
+ ...refs,
+ }),
+ )
+
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function))
+ addEventListenerSpy.mockRestore()
+ })
+
+ it('should not add resize listener for custom widths (max is fixed)', () => {
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
+ const refs = createMockRefs()
+
+ renderHook(() =>
+ usePaneWidth({
+ width: {min: '150px', default: '300px', max: '500px'},
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function))
+ addEventListenerSpy.mockRestore()
+ })
+
+ it('should add resize listener for preset widths when resizable', () => {
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
+ const refs = createMockRefs()
+
+ renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
+ addEventListenerSpy.mockRestore()
+ })
+
+ it('should cleanup resize listener on unmount', () => {
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
+ const refs = createMockRefs()
+
+ const {unmount} = renderHook(() =>
+ usePaneWidth({
+ width: 'medium',
+ minWidth: 256,
+ resizable: true,
+ widthStorageKey: 'test',
+ ...refs,
+ }),
+ )
+
+ unmount()
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
+ removeEventListenerSpy.mockRestore()
+ })
+ })
+})
+
+describe('helper functions', () => {
+ describe('isCustomWidthOptions', () => {
+ it('should return true for custom width objects', () => {
+ expect(isCustomWidthOptions({min: '100px', default: '200px', max: '300px'})).toBe(true)
+ })
+
+ it('should return false for preset width strings', () => {
+ expect(isCustomWidthOptions('small')).toBe(false)
+ expect(isCustomWidthOptions('medium')).toBe(false)
+ expect(isCustomWidthOptions('large')).toBe(false)
+ })
+ })
+
+ describe('isPaneWidth', () => {
+ it('should return true for valid preset widths', () => {
+ expect(isPaneWidth('small')).toBe(true)
+ expect(isPaneWidth('medium')).toBe(true)
+ expect(isPaneWidth('large')).toBe(true)
+ })
+
+ it('should return false for custom width objects', () => {
+ expect(isPaneWidth({min: '100px', default: '200px', max: '300px'})).toBe(false)
+ })
+ })
+
+ describe('getDefaultPaneWidth', () => {
+ it('should return correct default for preset widths', () => {
+ expect(getDefaultPaneWidth('small')).toBe(defaultPaneWidth.small)
+ expect(getDefaultPaneWidth('medium')).toBe(defaultPaneWidth.medium)
+ expect(getDefaultPaneWidth('large')).toBe(defaultPaneWidth.large)
+ })
+
+ it('should parse custom width default', () => {
+ expect(getDefaultPaneWidth({min: '100px', default: '250px', max: '400px'})).toBe(250)
+ })
+ })
+
+ describe('getPaneMaxWidthDiff', () => {
+ it('should return default when element is null', () => {
+ expect(getPaneMaxWidthDiff(null)).toBe(DEFAULT_MAX_WIDTH_DIFF)
+ })
+
+ it('should return default when CSS variable is not set', () => {
+ const element = document.createElement('div')
+ expect(getPaneMaxWidthDiff(element)).toBe(DEFAULT_MAX_WIDTH_DIFF)
+ })
+ })
+
+ describe('updateAriaValues', () => {
+ it('should set ARIA attributes on element', () => {
+ const handle = document.createElement('div')
+
+ updateAriaValues(handle, {min: 100, max: 500, current: 300})
+
+ expect(handle.getAttribute('aria-valuemin')).toBe('100')
+ expect(handle.getAttribute('aria-valuemax')).toBe('500')
+ expect(handle.getAttribute('aria-valuenow')).toBe('300')
+ expect(handle.getAttribute('aria-valuetext')).toBe('Pane width 300 pixels')
+ })
+
+ it('should handle null element gracefully', () => {
+ // Should not throw
+ expect(() => updateAriaValues(null, {min: 100, max: 500, current: 300})).not.toThrow()
+ })
+
+ it('should only update provided values', () => {
+ const handle = document.createElement('div')
+ handle.setAttribute('aria-valuemin', '50')
+ handle.setAttribute('aria-valuemax', '600')
+
+ updateAriaValues(handle, {current: 300})
+
+ // Original values unchanged
+ expect(handle.getAttribute('aria-valuemin')).toBe('50')
+ expect(handle.getAttribute('aria-valuemax')).toBe('600')
+ // Updated value
+ expect(handle.getAttribute('aria-valuenow')).toBe('300')
+ })
+ })
+})
+
+describe('constants', () => {
+ it('should export expected constants', () => {
+ expect(DEFAULT_MAX_WIDTH_DIFF).toBe(511)
+ expect(SSR_DEFAULT_MAX_WIDTH).toBe(600)
+ expect(ARROW_KEY_STEP).toBe(3)
+ expect(defaultPaneWidth).toEqual({small: 256, medium: 296, large: 320})
+ })
+
+ /**
+ * This test documents the CSS/JS coupling.
+ * The CSS variable --pane-max-width-diff changes at a breakpoint:
+ * - Below breakpoint: 511px (DEFAULT_MAX_WIDTH_DIFF)
+ * - At/above breakpoint: 959px
+ *
+ * The breakpoint value is exported from PageLayout.module.css via :export
+ * and imported into usePaneWidth.ts, so they stay in sync automatically.
+ */
+ it('should have DEFAULT_MAX_WIDTH_DIFF matching CSS value below breakpoint', () => {
+ // This constant must match --pane-max-width-diff in PageLayout.module.css
+ // for viewports below the breakpoint.
+ expect(DEFAULT_MAX_WIDTH_DIFF).toBe(511)
+ })
+})
diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts
new file mode 100644
index 00000000000..120403e68ea
--- /dev/null
+++ b/packages/react/src/PageLayout/usePaneWidth.ts
@@ -0,0 +1,289 @@
+import React from 'react'
+import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
+import cssExports from './PageLayout.module.css'
+
+// ----------------------------------------------------------------------------
+// Types
+
+type Measurement = `${number}px`
+
+export type CustomWidthOptions = {
+ min: Measurement
+ default: Measurement
+ max: Measurement
+}
+
+export type PaneWidth = 'small' | 'medium' | 'large'
+
+export type UsePaneWidthOptions = {
+ width: PaneWidth | CustomWidthOptions
+ minWidth: number
+ resizable: boolean
+ widthStorageKey: string
+ paneRef: React.RefObject
+ handleRef: React.RefObject
+}
+
+export type UsePaneWidthResult = {
+ /** Current width for React state (used in ARIA attributes) */
+ currentWidth: number
+ /** Mutable ref tracking width during drag operations */
+ currentWidthRef: React.MutableRefObject
+ /** Minimum allowed pane width */
+ minPaneWidth: number
+ /** Maximum allowed pane width (updates on viewport resize) */
+ maxPaneWidth: number
+ /** Calculate current max width constraint */
+ getMaxPaneWidth: () => number
+ /** Persist width to localStorage and sync React state */
+ saveWidth: (value: number) => void
+ /** Reset to default width */
+ getDefaultWidth: () => number
+}
+
+// ----------------------------------------------------------------------------
+// Constants
+
+/**
+ * Default value for --pane-max-width-diff CSS variable.
+ * Imported from CSS to ensure JS fallback matches the CSS default.
+ */
+export const DEFAULT_MAX_WIDTH_DIFF = Number(cssExports.paneMaxWidthDiffDefault)
+
+// --pane-max-width-diff changes at this breakpoint in PageLayout.module.css.
+const DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT = Number(cssExports.paneMaxWidthDiffBreakpoint)
+/**
+ * Default max pane width for SSR when viewport is unknown.
+ * Updated to actual value in layout effect before paint.
+ */
+export const SSR_DEFAULT_MAX_WIDTH = 600
+
+/**
+ * Pixel increment for keyboard arrow key resizing.
+ * @see https://github.com/github/accessibility/issues/5101#issuecomment-1822870655
+ */
+export const ARROW_KEY_STEP = 3
+
+/** Default widths for preset size options */
+export const defaultPaneWidth: Record = {small: 256, medium: 296, large: 320}
+
+// ----------------------------------------------------------------------------
+// Helper functions
+
+export const isCustomWidthOptions = (width: PaneWidth | CustomWidthOptions): width is CustomWidthOptions => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ return (width as CustomWidthOptions).default !== undefined
+}
+
+export const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => {
+ return ['small', 'medium', 'large'].includes(width as PaneWidth)
+}
+
+export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => {
+ if (isPaneWidth(w)) {
+ return defaultPaneWidth[w]
+ } else if (isCustomWidthOptions(w)) {
+ return parseInt(w.default, 10)
+ }
+ return 0
+}
+
+/**
+ * Gets the --pane-max-width-diff CSS variable value from a pane element.
+ * This value is set by CSS media queries and controls the max pane width constraint.
+ * Note: This calls getComputedStyle which forces layout - cache the result when possible.
+ */
+export function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number {
+ if (!paneElement) return DEFAULT_MAX_WIDTH_DIFF
+ const value = parseInt(getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff'), 10)
+ return value > 0 ? value : DEFAULT_MAX_WIDTH_DIFF
+}
+
+// Helper to update ARIA slider attributes via direct DOM manipulation
+// This avoids re-renders when values change during drag or on viewport resize
+export const updateAriaValues = (
+ handle: HTMLElement | null,
+ values: {current?: number; min?: number; max?: number},
+) => {
+ if (!handle) return
+ if (values.min !== undefined) handle.setAttribute('aria-valuemin', String(values.min))
+ if (values.max !== undefined) handle.setAttribute('aria-valuemax', String(values.max))
+ if (values.current !== undefined) {
+ handle.setAttribute('aria-valuenow', String(values.current))
+ handle.setAttribute('aria-valuetext', `Pane width ${values.current} pixels`)
+ }
+}
+
+// ----------------------------------------------------------------------------
+// Hook
+
+/**
+ * Manages pane width state with localStorage persistence and viewport constraints.
+ * Handles initialization from storage, clamping on viewport resize, and provides
+ * functions to save and reset width.
+ */
+export function usePaneWidth({
+ width,
+ minWidth,
+ resizable,
+ widthStorageKey,
+ paneRef,
+ handleRef,
+}: UsePaneWidthOptions): UsePaneWidthResult {
+ // Derive constraints from width configuration
+ const isCustomWidth = isCustomWidthOptions(width)
+ const minPaneWidth = isCustomWidth ? parseInt(width.min, 10) : minWidth
+ const customMaxWidth = isCustomWidth ? parseInt(width.max, 10) : null
+
+ // Cache the CSS variable value to avoid getComputedStyle during drag (causes layout thrashing)
+ // Updated on mount and resize when breakpoints might change
+ const maxWidthDiffRef = React.useRef(DEFAULT_MAX_WIDTH_DIFF)
+
+ // Calculate max width constraint - for custom widths this is fixed, otherwise viewport-dependent
+ const getMaxPaneWidth = React.useCallback(() => {
+ if (customMaxWidth !== null) return customMaxWidth
+ const viewportWidth = window.innerWidth
+ return viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth
+ }, [customMaxWidth, minPaneWidth])
+
+ // --- State ---
+ // Current width for React renders (ARIA attributes). Updates go through saveWidth() or clamp on resize.
+ const [currentWidth, setCurrentWidth] = React.useState(() => getDefaultPaneWidth(width))
+ // Mutable ref for drag operations - avoids re-renders on every pixel move
+ const currentWidthRef = React.useRef(currentWidth)
+ // Max width for ARIA - SSR uses custom max or a sensible default, updated on mount
+ const [maxPaneWidth, setMaxPaneWidth] = React.useState(() => customMaxWidth ?? SSR_DEFAULT_MAX_WIDTH)
+
+ // --- Callbacks ---
+ const getDefaultWidth = React.useCallback(() => getDefaultPaneWidth(width), [width])
+
+ const saveWidth = React.useCallback(
+ (value: number) => {
+ currentWidthRef.current = value
+ setCurrentWidth(value)
+ try {
+ localStorage.setItem(widthStorageKey, value.toString())
+ } catch {
+ // Ignore write errors (private browsing, quota exceeded, etc.)
+ }
+ },
+ [widthStorageKey],
+ )
+
+ // --- Effects ---
+ // Track whether we've initialized the width from localStorage
+ const initializedRef = React.useRef(false)
+
+ // Initialize from localStorage on mount (before paint to avoid CLS)
+ useIsomorphicLayoutEffect(() => {
+ if (initializedRef.current || !resizable) return
+ initializedRef.current = true
+
+ try {
+ const stored = localStorage.getItem(widthStorageKey)
+ if (stored !== null) {
+ const parsed = Number(stored)
+ if (!isNaN(parsed) && parsed > 0) {
+ currentWidthRef.current = parsed
+ paneRef.current?.style.setProperty('--pane-width', `${parsed}px`)
+ setCurrentWidth(parsed)
+ }
+ }
+ } catch {
+ // localStorage unavailable - keep default
+ }
+ }, [widthStorageKey, paneRef, resizable])
+
+ // Stable ref to getMaxPaneWidth for use in resize handler without re-subscribing
+ const getMaxPaneWidthRef = React.useRef(getMaxPaneWidth)
+ useIsomorphicLayoutEffect(() => {
+ getMaxPaneWidthRef.current = getMaxPaneWidth
+ })
+
+ // Update max pane width on mount and window resize for accurate ARIA values
+ useIsomorphicLayoutEffect(() => {
+ if (!resizable) return
+
+ let lastViewportWidth = window.innerWidth
+
+ const updateMax = ({forceRecalcCss = false}: {forceRecalcCss?: boolean} = {}) => {
+ // Track last viewport width to detect breakpoint crossings.
+ const currentViewportWidth = window.innerWidth
+ const crossedBreakpoint =
+ (lastViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT &&
+ currentViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT) ||
+ (lastViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT &&
+ currentViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT)
+ lastViewportWidth = currentViewportWidth
+
+ // Only call getComputedStyle if we crossed the breakpoint (expensive operation)
+ if (forceRecalcCss || 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) {
+ currentWidthRef.current = actualMax
+ paneRef.current?.style.setProperty('--pane-width', `${actualMax}px`)
+ setCurrentWidth(actualMax)
+ }
+
+ updateAriaValues(handleRef.current, {min: minPaneWidth, max: actualMax, current: currentWidthRef.current})
+ }
+
+ // Initial calculation - force CSS recalc to get correct value
+ updateMax({forceRecalcCss: true})
+
+ // 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
+
+ const handleResize = () => {
+ const now = Date.now()
+ const timeSinceLastExecution = now - lastExecution
+
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
+
+ if (timeSinceLastExecution >= THROTTLE_MS) {
+ lastExecution = now
+ updateMax()
+ } else {
+ // Schedule trailing edge execution
+ timeoutId = setTimeout(() => {
+ lastExecution = Date.now()
+ updateMax()
+ timeoutId = null
+ }, THROTTLE_MS - timeSinceLastExecution)
+ }
+ }
+
+ // 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)
+ window.removeEventListener('resize', handleResize)
+ }
+ }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef])
+
+ return {
+ currentWidth,
+ currentWidthRef,
+ minPaneWidth,
+ maxPaneWidth,
+ getMaxPaneWidth,
+ saveWidth,
+ getDefaultWidth,
+ }
+}