diff --git a/.changeset/shiny-buckets-study.md b/.changeset/shiny-buckets-study.md new file mode 100644 index 00000000000..5888789fad1 --- /dev/null +++ b/.changeset/shiny-buckets-study.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +fix resizeobserver perf regression diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index a90c9e9f7ec..dd46fd0fcca 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -1,3 +1,11 @@ +/* Exported values for JavaScript consumption */ +:export { + /* Breakpoint where --pane-max-width-diff changes (used in usePaneWidth.ts) */ + paneMaxWidthDiffBreakpoint: 1280; + /* Default value for --pane-max-width-diff below the breakpoint */ + paneMaxWidthDiffDefault: 511; +} + .PageLayoutRoot { /* Region Order */ --region-order-header: 0; @@ -19,6 +27,7 @@ --pane-width-small: 100%; --pane-width-medium: 100%; --pane-width-large: 100%; + /* NOTE: This value is exported via :export for use in usePaneWidth.ts */ --pane-max-width-diff: 511px; @media screen and (min-width: 768px) { @@ -33,6 +42,7 @@ --pane-width-large: 320px; } + /* NOTE: This breakpoint value is exported via :export for use in usePaneWidth.ts */ @media screen and (min-width: 1280px) { --pane-max-width-diff: 959px; } @@ -373,10 +383,10 @@ /** * OPTIMIZATION: Aggressive containment during drag for ContentWrapper - * CSS handles most optimizations automatically via :has() selector - * JavaScript only handles scroll locking (can't be done in CSS) + * data-dragging is set on PageLayoutContent by JavaScript + * This avoids expensive :has() selectors */ -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper { +.PageLayoutContent[data-dragging='true'] .ContentWrapper { /* Add paint containment during drag - safe since user can't interact */ contain: layout style paint; @@ -422,7 +432,7 @@ * This prevents expensive recalculations of large content areas * while keeping content visible (just frozen in place) */ -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .Content { +.PageLayoutContent[data-dragging='true'] .Content { /* Full containment (without size) - isolate from layout recalculations */ contain: layout style paint; } @@ -618,9 +628,10 @@ /** * OPTIMIZATION: Performance enhancements for Pane during drag - * CSS handles all optimizations automatically - JavaScript only locks scroll + * data-dragging is set on PageLayoutContent by JavaScript + * This avoids expensive :has() selectors */ -.PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane { +.PageLayoutContent[data-dragging='true'] .Pane { /* Full containment - isolate from layout recalculations */ contain: layout style paint; diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index b1b887999ba..ad7f81d6046 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -1,9 +1,11 @@ -import React from 'react' +import React, {useState} from 'react' import type {Meta, StoryObj} from '@storybook/react-vite' import {PageLayout} from './PageLayout' import {Button} from '../Button' import Label from '../Label' import Heading from '../Heading' +import Autocomplete from '../Autocomplete' +import FormControl from '../FormControl' const meta: Meta = { title: 'Components/PageLayout/Performance Tests', @@ -14,6 +16,47 @@ export default meta type Story = StoryObj +// Autocomplete suggestions data +const suggestions = [ + {id: '1', text: 'JavaScript'}, + {id: '2', text: 'TypeScript'}, + {id: '3', text: 'React'}, + {id: '4', text: 'Vue'}, + {id: '5', text: 'Angular'}, + {id: '6', text: 'Svelte'}, + {id: '7', text: 'Node.js'}, + {id: '8', text: 'Python'}, + {id: '9', text: 'Ruby'}, + {id: '10', text: 'Go'}, +] + +// Reusable stateful autocomplete search component +function SearchInput() { + const [filterValue, setFilterValue] = useState('') + const filteredItems = suggestions.filter(item => item.text.toLowerCase().includes(filterValue.toLowerCase())) + + return ( + + Search + + setFilterValue(e.target.value)} + placeholder="Search items..." + /> + + + + + + ) +} + // ============================================================================ // Story 1: Baseline - Light Content (~100 elements) // ============================================================================ @@ -29,7 +72,8 @@ export const BaselineLight: Story = {
-

Minimal DOM elements to establish baseline.

+ +

Minimal DOM elements to establish baseline.

Should be effortless 60 FPS.

@@ -58,7 +102,6 @@ export const MediumContent: Story = {
-

Performance Monitor

+ {/* Large table with complex cells */} -

Data Table (300 rows × 10 columns)

+

Data Table (300 rows × 10 columns)

+ {/* Section 1: Large card grid */} -
+

Activity Feed (200 cards)

{Array.from({length: 200}).map((_, i) => ( diff --git a/packages/react/src/PageLayout/PageLayout.test.tsx b/packages/react/src/PageLayout/PageLayout.test.tsx index 370f6e6ca33..8220f425c4e 100644 --- a/packages/react/src/PageLayout/PageLayout.test.tsx +++ b/packages/react/src/PageLayout/PageLayout.test.tsx @@ -174,6 +174,61 @@ describe('PageLayout', async () => { const finalWidth = (pane as HTMLElement).style.getPropertyValue('--pane-width') expect(finalWidth).not.toEqual(initialWidth) }) + + it('should set data-dragging attribute during pointer drag', async () => { + const {container} = render( + + + + + + + + , + ) + + const content = container.querySelector('[class*="PageLayoutContent"]') + const divider = await screen.findByRole('slider') + + // Before drag - no data-dragging attribute + expect(content).not.toHaveAttribute('data-dragging') + + // Start drag + fireEvent.pointerDown(divider, {clientX: 300, clientY: 200, pointerId: 1}) + expect(content).toHaveAttribute('data-dragging', 'true') + + // End drag - pointer capture lost ends the drag and removes attribute + fireEvent.lostPointerCapture(divider, {pointerId: 1}) + expect(content).not.toHaveAttribute('data-dragging') + }) + + it('should set data-dragging attribute during keyboard resize', async () => { + const {container} = render( + + + + + + + + , + ) + + const content = container.querySelector('[class*="PageLayoutContent"]') + const divider = await screen.findByRole('slider') + + // Before interaction - no data-dragging attribute + expect(content).not.toHaveAttribute('data-dragging') + + // Start keyboard resize (focus first) + fireEvent.focus(divider) + fireEvent.keyDown(divider, {key: 'ArrowRight'}) + expect(content).toHaveAttribute('data-dragging', 'true') + + // End keyboard resize - removes attribute + fireEvent.keyUp(divider, {key: 'ArrowRight'}) + expect(content).not.toHaveAttribute('data-dragging') + }) }) describe('PageLayout.Content', () => { diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 3aafaf85b22..49d2f9cfb8c 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -11,62 +11,15 @@ import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes import classes from './PageLayout.module.css' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types' -import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' - -// Module-scoped ResizeObserver subscription for viewport width tracking -let viewportWidthListeners: Set<() => void> | undefined -let viewportWidthObserver: ResizeObserver | undefined - -function subscribeToViewportWidth(callback: () => void) { - if (!viewportWidthListeners) { - viewportWidthListeners = new Set() - viewportWidthObserver = new ResizeObserver(() => { - if (viewportWidthListeners) { - for (const listener of viewportWidthListeners) { - listener() - } - } - }) - viewportWidthObserver.observe(document.documentElement) - } - - viewportWidthListeners.add(callback) - - return () => { - viewportWidthListeners?.delete(callback) - if (viewportWidthListeners?.size === 0) { - viewportWidthObserver?.disconnect() - viewportWidthObserver = undefined - viewportWidthListeners = undefined - } - } -} - -function getViewportWidth() { - return window.innerWidth -} - -function getServerViewportWidth() { - return 0 -} - -/** - * Custom hook that subscribes to viewport width changes using a shared ResizeObserver - */ -function useViewportWidth() { - return React.useSyncExternalStore(subscribeToViewportWidth, getViewportWidth, getServerViewportWidth) -} - -/** - * 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. - * Falls back to 511 (the CSS default) if the value cannot be read. - */ -function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number { - if (!paneElement) return 511 - const value = parseInt(getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff'), 10) - return value > 0 ? value : 511 -} +import { + usePaneWidth, + updateAriaValues, + isCustomWidthOptions, + isPaneWidth, + ARROW_KEY_STEP, + type CustomWidthOptions, + type PaneWidth, +} from './usePaneWidth' const REGION_ORDER = { header: 0, @@ -88,11 +41,13 @@ const PageLayoutContext = React.createContext<{ rowGap: keyof typeof SPACING_MAP columnGap: keyof typeof SPACING_MAP paneRef: React.RefObject + contentRef: React.RefObject }>({ padding: 'normal', rowGap: 'normal', columnGap: 'normal', paneRef: {current: null}, + contentRef: {current: null}, }) // ---------------------------------------------------------------------------- @@ -132,6 +87,7 @@ const Root: React.FC> = ({ _slotsConfig: slotsConfig, }) => { const paneRef = useRef(null) + const contentRef = useRef(null) const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer}) @@ -141,8 +97,9 @@ const Root: React.FC> = ({ rowGap, columnGap, paneRef, + contentRef, } - }, [padding, rowGap, columnGap, paneRef]) + }, [padding, rowGap, columnGap, paneRef, contentRef]) return ( @@ -157,7 +114,9 @@ const Root: React.FC> = ({ >
{slots.header} -
{rest}
+
+ {rest} +
{slots.footer}
@@ -200,24 +159,44 @@ const HorizontalDivider: React.FC> = ({ ) } -type DraggableDividerProps = { +type VerticalDividerProps = DividerProps & { draggable?: boolean +} + +const VerticalDivider: React.FC> = ({ + variant = 'none', + position, + className, + style, + children, +}) => { + return ( +
+ {children} +
+ ) +} + +type DragHandleProps = { + /** Ref for imperative ARIA updates during drag */ handleRef: React.RefObject + /** Called with movement delta on each drag tick */ onDrag: (delta: number, isKeyboard: boolean) => void + /** Called when drag operation completes */ onDragEnd: () => void - onDoubleClick: () => void -} - -// Helper to update ARIA slider attributes via direct DOM manipulation -// This avoids re-renders when values change during drag or on viewport resize -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`) - } + /** Reset width on double-click */ + onDoubleClick?: React.MouseEventHandler + /** ARIA slider min value */ + 'aria-valuemin'?: number + /** ARIA slider max value */ + 'aria-valuemax'?: number + /** ARIA slider current value */ + 'aria-valuenow'?: number } const DATA_DRAGGING_ATTR = 'data-dragging' @@ -225,16 +204,19 @@ const isDragging = (handle: HTMLElement | null) => { return handle?.getAttribute(DATA_DRAGGING_ATTR) === 'true' } -const VerticalDivider: React.FC> = ({ - variant = 'none', - draggable = false, +/** + * DragHandle - handles all pointer and keyboard interactions for resizing + * ARIA values are set in JSX for SSR accessibility, + * then updated via DOM manipulation during drag for performance + */ +const DragHandle: React.FC = ({ handleRef, onDrag, onDragEnd, onDoubleClick, - position, - className, - style, + 'aria-valuemin': ariaValueMin, + 'aria-valuemax': ariaValueMax, + 'aria-valuenow': ariaValueNow, }) => { const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) @@ -243,20 +225,38 @@ const VerticalDivider: React.FC { + if (dragging) { + handleRef.current?.setAttribute(DATA_DRAGGING_ATTR, 'true') + contentRef.current?.setAttribute(DATA_DRAGGING_ATTR, 'true') + } else { + handleRef.current?.removeAttribute(DATA_DRAGGING_ATTR) + contentRef.current?.removeAttribute(DATA_DRAGGING_ATTR) + } + }, + [handleRef, contentRef], + ) /** * Pointer down starts a drag operation * Capture the pointer to continue receiving events outside the handle area * Set a data attribute to indicate dragging state */ - const handlePointerDown = React.useCallback((event: React.PointerEvent) => { - if (event.button !== 0) return - event.preventDefault() - const target = event.currentTarget - target.setPointerCapture(event.pointerId) - target.setAttribute(DATA_DRAGGING_ATTR, 'true') - }, []) + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + const target = event.currentTarget + target.setPointerCapture(event.pointerId) + setDragging(true) + }, + [setDragging], + ) /** * Pointer move during drag @@ -293,15 +293,11 @@ const VerticalDivider: React.FC) => { - if (!isDragging(handleRef.current)) return - const target = event.currentTarget - target.removeAttribute(DATA_DRAGGING_ATTR) - stableOnDragEnd.current() - }, - [handleRef], - ) + const handleLostPointerCapture = React.useCallback(() => { + if (!isDragging(handleRef.current)) return + setDragging(false) + stableOnDragEnd.current() + }, [handleRef, setDragging]) /** * Keyboard handling for accessibility @@ -323,60 +319,50 @@ const VerticalDivider: React.FC) => { - if ( - event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || - event.key === 'ArrowUp' || - event.key === 'ArrowDown' - ) { - event.preventDefault() - event.currentTarget.removeAttribute(DATA_DRAGGING_ATTR) - stableOnDragEnd.current() - } - }, []) + const handleKeyUp = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown' + ) { + event.preventDefault() + setDragging(false) + stableOnDragEnd.current() + } + }, + [setDragging], + ) return (
- {draggable ? ( - // Drag handle - ARIA attributes set via DOM manipulation for performance -
- ) : null} -
+ ref={handleRef} + className={classes.DraggableHandle} + role="slider" + aria-label="Draggable pane splitter" + aria-valuemin={ariaValueMin} + aria-valuemax={ariaValueMax} + aria-valuenow={ariaValueNow} + aria-valuetext={ariaValueNow !== undefined ? `Pane width ${ariaValueNow} pixels` : undefined} + tabIndex={0} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onLostPointerCapture={handleLostPointerCapture} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onDoubleClick={onDoubleClick} + /> ) } @@ -549,34 +535,6 @@ Content.displayName = 'PageLayout.Content' // ---------------------------------------------------------------------------- // PageLayout.Pane -type Measurement = `${number}px` - -type CustomWidthOptions = { - min: Measurement - default: Measurement - max: Measurement -} - -type PaneWidth = keyof typeof paneWidths - -const isCustomWidthOptions = (width: PaneWidth | CustomWidthOptions): width is CustomWidthOptions => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return (width as CustomWidthOptions).default !== undefined -} - -const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => { - return ['small', 'medium', 'large'].includes(width as PaneWidth) -} - -const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => { - if (isPaneWidth(w)) { - return defaultPaneWidth[w] - } else if (isCustomWidthOptions(w)) { - return parseInt(w.default, 10) - } - return 0 -} - export type PageLayoutPaneProps = { position?: keyof typeof panePositions | ResponsiveValue /** @@ -631,15 +589,6 @@ const panePositions = { end: REGION_ORDER.paneEnd, } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const paneWidths = { - small: ['100%', null, '240px', '256px'], - medium: ['100%', null, '256px', '296px'], - large: ['100%', null, '256px', '320px'], -} - -const defaultPaneWidth = {small: 256, medium: 296, large: 320} - const overflowProps = {tabIndex: 0, role: 'region'} const Pane = React.forwardRef>( @@ -685,65 +634,18 @@ const Pane = React.forwardRef { - // Only initialize once on mount - subsequent updates come from drag operations - if (initializedRef.current || !resizable) return - initializedRef.current = true - // Before paint, check localStorage for a stored width - try { - const value = localStorage.getItem(widthStorageKey) - if (value !== null && !isNaN(Number(value))) { - const num = Number(value) - currentWidthRef.current = num - paneRef.current?.style.setProperty('--pane-width', `${num}px`) - return - } - } catch { - // localStorage unavailable - set default via DOM - } - paneRef.current?.style.setProperty('--pane-width', `${defaultWidth}px`) - }, [widthStorageKey, paneRef, resizable, defaultWidth]) - - // Subscribe to viewport width changes for responsive max constraint calculation - const viewportWidth = useViewportWidth() - - // Calculate min width constraint from width configuration - const minPaneWidth = isCustomWidthOptions(width) ? parseInt(width.min, 10) : minWidth - - // Cache max width constraint - updated when viewport changes (which triggers CSS breakpoint changes) - // This avoids calling getComputedStyle() on every drag frame - const maxPaneWidthRef = React.useRef(minPaneWidth) - React.useEffect(() => { - if (isCustomWidthOptions(width)) { - maxPaneWidthRef.current = parseInt(width.max, 10) - } else { - const maxWidthDiff = getPaneMaxWidthDiff(paneRef.current) - maxPaneWidthRef.current = - viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiff) : minPaneWidth - } - }, [width, minPaneWidth, viewportWidth, paneRef]) - // Ref to the drag handle for updating ARIA attributes const handleRef = React.useRef(null) - // Update ARIA attributes on mount and when viewport/constraints change - useIsomorphicLayoutEffect(() => { - updateAriaValues(handleRef.current, { - min: minPaneWidth, - max: maxPaneWidthRef.current, - current: currentWidthRef.current!, + const {currentWidth, currentWidthRef, minPaneWidth, maxPaneWidth, getMaxPaneWidth, saveWidth, getDefaultWidth} = + usePaneWidth({ + width, + minWidth, + resizable, + widthStorageKey, + paneRef, + handleRef, }) - }, [minPaneWidth, viewportWidth]) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -767,14 +669,6 @@ const Pane = React.forwardRef { - try { - localStorage.setItem(widthStorageKey, value.toString()) - } catch { - // Ignore write errors - } - } - return (
@@ -837,62 +733,67 @@ const Pane = React.forwardRef { - const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta - const maxWidth = maxPaneWidthRef.current - - if (isKeyboard) { - // Clamp keyboard delta to stay within bounds - const newWidth = Math.max(minPaneWidth, Math.min(maxWidth, currentWidthRef.current! + deltaWithDirection)) - if (newWidth !== currentWidthRef.current) { - currentWidthRef.current = newWidth - paneRef.current?.style.setProperty('--pane-width', `${newWidth}px`) - updateAriaValues(handleRef.current, {current: newWidth}) - } - } else { - // Apply delta directly via CSS variable for immediate visual feedback - if (paneRef.current) { - const newWidth = currentWidthRef.current! + deltaWithDirection - const clampedWidth = Math.max(minPaneWidth, Math.min(maxWidth, newWidth)) - - // Only update if the clamped width actually changed - // This prevents drift when dragging against min/max constraints - if (clampedWidth !== currentWidthRef.current) { - paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) - currentWidthRef.current = clampedWidth - updateAriaValues(handleRef.current, {current: clampedWidth}) - } - } - } - }} - // Save final width to localStorage (skip React state update to avoid reconciliation) - onDragEnd={() => { - // For mouse drag: The CSS variable is already set and currentWidthRef is in sync. - // We intentionally skip setPaneWidth() to avoid triggering expensive React - // reconciliation with large DOM trees. The ref is the source of truth for - // subsequent drag operations. - setWidthInLocalStorage(currentWidthRef.current!) - }} position={positionProp} - // Reset pane width on double click - onDoubleClick={() => { - const defaultWidth = getDefaultPaneWidth(width) - // Update CSS variable and ref directly - skip React state to avoid reconciliation - if (paneRef.current) { - paneRef.current.style.setProperty('--pane-width', `${defaultWidth}px`) - currentWidthRef.current = defaultWidth - updateAriaValues(handleRef.current, {current: defaultWidth}) - } - setWidthInLocalStorage(defaultWidth) - }} className={classes.PaneVerticalDivider} style={ { '--spacing': `var(--spacing-${columnGap})`, } as React.CSSProperties } - /> + > + {resizable ? ( + { + const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta + const maxWidth = getMaxPaneWidth() + + if (isKeyboard) { + // Clamp keyboard delta to stay within bounds + const newWidth = Math.max( + minPaneWidth, + Math.min(maxWidth, currentWidthRef.current! + deltaWithDirection), + ) + if (newWidth !== currentWidthRef.current) { + currentWidthRef.current = newWidth + paneRef.current?.style.setProperty('--pane-width', `${newWidth}px`) + updateAriaValues(handleRef.current, {current: newWidth, max: maxWidth}) + } + } else { + // Apply delta directly via CSS variable for immediate visual feedback + if (paneRef.current) { + const newWidth = currentWidthRef.current! + deltaWithDirection + const clampedWidth = Math.max(minPaneWidth, Math.min(maxWidth, newWidth)) + + // Only update if the clamped width actually changed + // This prevents drift when dragging against min/max constraints + if (clampedWidth !== currentWidthRef.current) { + paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) + currentWidthRef.current = clampedWidth + updateAriaValues(handleRef.current, {current: clampedWidth, max: maxWidth}) + } + } + } + }} + onDragEnd={() => { + // Sync React state so parent re-renders use the correct width + saveWidth(currentWidthRef.current!) + }} + onDoubleClick={() => { + const resetWidth = getDefaultWidth() + if (paneRef.current) { + paneRef.current.style.setProperty('--pane-width', `${resetWidth}px`) + currentWidthRef.current = resetWidth + updateAriaValues(handleRef.current, {current: resetWidth}) + } + saveWidth(resetWidth) + }} + /> + ) : null} +
) }, 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, + } +}