diff --git a/.changeset/olive-heads-enter.md b/.changeset/olive-heads-enter.md deleted file mode 100644 index e95379932a0..00000000000 --- a/.changeset/olive-heads-enter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@primer/react': patch ---- - -Improve drag performance for PageLayout diff --git a/e2e/components/Axe.test.ts b/e2e/components/Axe.test.ts index 714ac97c6f0..74b17d88f6b 100644 --- a/e2e/components/Axe.test.ts +++ b/e2e/components/Axe.test.ts @@ -14,8 +14,6 @@ const SKIPPED_TESTS = [ 'components-flash-features--with-icon-action-dismiss', // TODO: Remove once color-contrast issues have been resolved 'components-flash-features--with-icon-and-action', // TODO: Remove once color-contrast issues have been resolved 'components-filteredactionlist--default', - 'components-pagelayout-performance-tests--medium-content', - 'components-pagelayout-performance-tests--heavy-content', ] type Component = { diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 02dc721bae0..5c3041059ea 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -1,3 +1,15 @@ +/* Maintain resize cursor while dragging */ +/* stylelint-disable-next-line selector-no-qualifying-type */ +body[data-page-layout-dragging='true'] { + cursor: col-resize; +} + +/* Disable text selection while dragging */ +/* stylelint-disable-next-line selector-no-qualifying-type */ +body[data-page-layout-dragging='true'] * { + user-select: none; +} + .PageLayoutRoot { /* Region Order */ --region-order-header: 0; @@ -345,12 +357,6 @@ flex-grow: 1; flex-shrink: 1; - /** - * OPTIMIZATION: Isolate content area from rest of page - * Note: No 'paint' containment to allow overflow effects (tooltips, modals, etc.) - */ - contain: layout style; - &:where([data-is-hidden='true']) { display: none; } @@ -377,26 +383,6 @@ } } -/** - * 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) - */ -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper { - /* Add paint containment during drag - safe since user can't interact */ - contain: layout style paint; - - /* Disable interactions */ - pointer-events: none; - - /* Disable transitions to prevent expensive recalculations */ - transition: none; - - /* Force compositor layer for hardware acceleration */ - will-change: width; - transform: translateZ(0); -} - .Content { width: 100%; @@ -406,14 +392,6 @@ margin-left: auto; flex-grow: 1; - /** - * OPTIMIZATION: Skip rendering off-screen content during scrolling/resizing - * This automatically helps consumers with large content by only rendering - * elements that are visible in the viewport - */ - content-visibility: auto; - contain-intrinsic-size: auto 500px; - &:where([data-width='medium']) { max-width: 768px; } @@ -431,16 +409,6 @@ } } -/** - * OPTIMIZATION: Freeze content layout during resize drag - * This prevents expensive recalculations of large content areas - * while keeping content visible (just frozen in place) - */ -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .Content { - /* Full containment (without size) - isolate from layout recalculations */ - contain: layout style paint; -} - .PaneWrapper { display: flex; width: 100%; @@ -617,15 +585,6 @@ /* stylelint-disable-next-line primer/spacing */ padding: var(--spacing); - /** - * OPTIMIZATION: Full containment for pane - isolates from rest of page - */ - contain: layout style paint; - /** - * OPTIMIZATION: For extremely tall content - skip rendering off-screen content - */ - content-visibility: auto; - @media screen and (min-width: 768px) { overflow: auto; } @@ -639,26 +598,6 @@ } } -/** - * OPTIMIZATION: Performance enhancements for Pane during drag - * CSS handles all optimizations automatically - JavaScript only locks scroll - */ -.PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane { - /* Full containment - isolate from layout recalculations */ - contain: layout style paint; - - /* Disable interactions during drag */ - pointer-events: none; - - /* Disable transitions during drag */ - transition: none; - - /* Force hardware acceleration */ - will-change: width, transform; - transform: translateZ(0); - backface-visibility: hidden; -} - .PaneHorizontalDivider { &:where([data-position='start']) { /* stylelint-disable-next-line primer/spacing */ @@ -757,22 +696,12 @@ padding: var(--spacing); } -/** - * DraggableHandle - Interactive resize handle - */ .DraggableHandle { position: absolute; inset: 0 -2px; cursor: col-resize; background-color: transparent; transition-delay: 0.1s; - - /** - * OPTIMIZATION: Prevent touch scrolling and text selection during drag - * This is done in CSS because it needs to be set before any pointer events - */ - touch-action: none; - user-select: none; } .DraggableHandle:hover { @@ -781,7 +710,6 @@ .DraggableHandle[data-dragging='true'] { background-color: var(--bgColor-accent-emphasis); - cursor: col-resize; } .DraggableHandle[data-dragging='true']:hover { diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx deleted file mode 100644 index b1b887999ba..00000000000 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ /dev/null @@ -1,492 +0,0 @@ -import React 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' - -const meta: Meta = { - title: 'Components/PageLayout/Performance Tests', - component: PageLayout, -} - -export default meta - -type Story = StoryObj - -// ============================================================================ -// Story 1: Baseline - Light Content (~100 elements) -// ============================================================================ - -export const BaselineLight: Story = { - name: '1. Light Content - Baseline (~100 elements)', - render: () => { - return ( - - - Light Content Baseline - - - -
-

Minimal DOM elements to establish baseline.

-

Should be effortless 60 FPS.

-
-
- - -
-

Drag to test - should be instant.

-
-
-
- ) - }, -} - -// ============================================================================ -// Story 2: Medium Content - Virtualized Table (~3000 elements) -// ============================================================================ - -export const MediumContent: Story = { - name: '2. Medium Content - Large Table (~3000 elements)', - render: () => { - return ( - - - Medium Content - Large Table - - -
-

Performance Monitor

-
- DOM Load: ~3,000 elements -
- Table: 300 rows × 10 cols -
-
-
-
- -
- {/* Large table with complex cells */} -

Data Table (300 rows × 10 columns)

-
- - - - {['ID', 'Name', 'Email', 'Role', 'Status', 'Date', 'Count', 'Value', 'Tags', 'Actions'].map( - (header, i) => ( - - ), - )} - - - - {Array.from({length: 300}).map((_, rowIndex) => ( - - - - - - - - - - - - - ))} - -
- {header} -
#{10000 + rowIndex} - {['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'][rowIndex % 5]}{' '} - {['Smith', 'Jones', 'Davis'][rowIndex % 3]} - - user{rowIndex}@example.com - - {['Admin', 'Editor', 'Viewer', 'Manager'][rowIndex % 4]} - - - - 2024-{String((rowIndex % 12) + 1).padStart(2, '0')}- - {String((rowIndex % 28) + 1).padStart(2, '0')} - - {(rowIndex * 17) % 1000} - - ${((rowIndex * 123.45) % 10000).toFixed(2)} - - - - - - -
-
-
-
-
- ) - }, -} - -// ============================================================================ -// Story 3: Heavy Content - Multiple Sections (~5000 elements) -// ============================================================================ - -export const HeavyContent: Story = { - name: '3. Heavy Content - Multiple Sections (~5000 elements)', - render: () => { - return ( - - - Heavy Content - Multiple Sections (~5000 elements) - - - -
-
- DOM Load: ~5,000 elements -
- Mix: Cards, tables, lists -
-
-

- Sections: -

-
    -
  • 200 activity cards (~1000 elem)
  • -
  • 150-row table (~1200 elem)
  • -
  • 200 issue items (~1200 elem)
  • -
  • + Headers, buttons, etc
  • -
-
-
- - -
- {/* Section 1: Large card grid */} -
-

Activity Feed (200 cards)

-
- {Array.from({length: 200}).map((_, i) => ( -
-
- Activity #{i + 1} - {i % 60}m ago -
-
- User {['Alice', 'Bob', 'Charlie'][i % 3]} performed action on item {i} -
-
- - -
-
- ))} -
-
- - {/* Section 2: Large table */} -
-

Data Table (150 rows × 8 columns)

- - - - {['ID', 'Name', 'Type', 'Status', 'Date', 'Value', 'Priority', 'Owner'].map((header, i) => ( - - ))} - - - - {Array.from({length: 150}).map((_, i) => ( - - - - - - - - - - - ))} - -
- {header} -
#{5000 + i}Item {i + 1} - {['Type A', 'Type B', 'Type C', 'Type D'][i % 4]} - - - - Dec {(i % 30) + 1} - ${(i * 50 + 100).toFixed(2)}{['Low', 'Medium', 'High'][i % 3]}user{i % 20}
-
- - {/* Section 3: List with nested content */} -
-

Issue Tracker (200 items)

- {Array.from({length: 200}).map((_, i) => ( -
-
-
- Issue #{i + 1} - -
- {i % 10}d ago -
-
- Description for issue {i + 1}: This is some text that describes the issue in detail. -
-
- 👤 {['alice', 'bob', 'charlie'][i % 3]} - 💬 {i % 15} comments - ⭐ {i % 20} reactions -
-
- ))} -
-
-
-
- ) - }, -} - -export const ResponsiveConstraintsTest: Story = { - render: () => { - const [viewportWidth, setViewportWidth] = React.useState(typeof window !== 'undefined' ? window.innerWidth : 1280) - - React.useEffect(() => { - const handleResize = () => setViewportWidth(window.innerWidth) - // eslint-disable-next-line github/prefer-observers - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, []) - - const maxWidthDiff = viewportWidth >= 1280 ? 959 : 511 - const calculatedMaxWidth = Math.max(256, viewportWidth - maxWidthDiff) - - return ( - - - Responsive Constraints Test - - - -
-

Max width: {calculatedMaxWidth}px

-
-
- - -
-

Test responsive max width constraints

-

Resize window and watch max pane width update.

-
-
-
- ) - }, -} - -export const KeyboardARIATest: Story = { - name: 'Keyboard & ARIA Test', - render: () => { - const [ariaAttributes, setAriaAttributes] = React.useState({ - valuemin: '—', - valuemax: '—', - valuenow: '—', - valuetext: '—', - }) - - React.useEffect(() => { - if (typeof window === 'undefined') { - return undefined - } - - const ATTRIBUTE_NAMES = ['aria-valuemin', 'aria-valuemax', 'aria-valuenow', 'aria-valuetext'] as const - const attributeFilter = ATTRIBUTE_NAMES.map(attribute => attribute) - let handleElement: HTMLElement | null = null - const mutationObserver = new MutationObserver(() => { - if (!handleElement) return - setAriaAttributes({ - valuemin: handleElement.getAttribute('aria-valuemin') ?? '—', - valuemax: handleElement.getAttribute('aria-valuemax') ?? '—', - valuenow: handleElement.getAttribute('aria-valuenow') ?? '—', - valuetext: handleElement.getAttribute('aria-valuetext') ?? '—', - }) - }) - - const attachObserver = () => { - handleElement = document.querySelector("[role='slider'][aria-label='Draggable pane splitter']") - if (!handleElement) return false - - mutationObserver.observe(handleElement, { - attributes: true, - attributeFilter, - }) - - setAriaAttributes({ - valuemin: handleElement.getAttribute('aria-valuemin') ?? '—', - valuemax: handleElement.getAttribute('aria-valuemax') ?? '—', - valuenow: handleElement.getAttribute('aria-valuenow') ?? '—', - valuetext: handleElement.getAttribute('aria-valuetext') ?? '—', - }) - - return true - } - - const retryInterval = window.setInterval(() => { - if (attachObserver()) { - window.clearInterval(retryInterval) - } - }, 100) - - return () => { - window.clearInterval(retryInterval) - mutationObserver.disconnect() - } - }, []) - - return ( - - - Keyboard & ARIA Test - - - -
-

Use keyboard: ← → ↑ ↓

-
-
- - -
-

Test Instructions

-
    -
  1. Tab to resize handle
  2. -
  3. Use arrow keys to resize
  4. -
  5. Test with screen reader
  6. -
-
-

Live ARIA attributes

-
-
aria-valuemin
-
{ariaAttributes.valuemin}
-
aria-valuemax
-
{ariaAttributes.valuemax}
-
aria-valuenow
-
{ariaAttributes.valuenow}
-
aria-valuetext
-
{ariaAttributes.valuetext}
-
-

- Values update live when the slider handle changes size via keyboard or pointer interactions. -

-
-
-
-
- ) - }, -} diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 3aafaf85b22..29afbe4477a 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -5,68 +5,13 @@ import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' import type {ResponsiveValue} from '../hooks/useResponsiveValue' import {isResponsiveValue} from '../hooks/useResponsiveValue' import {useSlots} from '../hooks/useSlots' +import {canUseDOM} from '../utils/environment' import {useOverflow} from '../hooks/useOverflow' import {warning} from '../utils/warning' 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 -} const REGION_ORDER = { header: 0, @@ -202,33 +147,16 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean - handleRef: React.RefObject - onDrag: (delta: number, isKeyboard: boolean) => void - 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`) - } -} - -const DATA_DRAGGING_ATTR = 'data-dragging' -const isDragging = (handle: HTMLElement | null) => { - return handle?.getAttribute(DATA_DRAGGING_ATTR) === 'true' + onDragStart?: () => void + onDrag?: (delta: number, isKeyboard: boolean) => void + onDragEnd?: () => void + onDoubleClick?: () => void } const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, - handleRef, + onDragStart, onDrag, onDragEnd, onDoubleClick, @@ -236,114 +164,100 @@ const VerticalDivider: React.FC { + const [isDragging, setIsDragging] = React.useState(false) + const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) + const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) - React.useEffect(() => { - stableOnDrag.current = onDrag - stableOnDragEnd.current = onDragEnd - }) const {paneRef} = React.useContext(PageLayoutContext) - /** - * 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 [minWidth, setMinWidth] = React.useState(0) + const [maxWidth, setMaxWidth] = React.useState(0) + const [currentWidth, setCurrentWidth] = React.useState(0) - /** - * Pointer move during drag - * Calls onDrag with movement delta - * Prevents default to avoid unwanted selection behavior - */ - const handlePointerMove = React.useCallback( - (event: React.PointerEvent) => { - if (!isDragging(handleRef.current)) return - event.preventDefault() + 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]) - if (event.movementX !== 0) { - stableOnDrag.current(event.movementX, false) - } - }, - [handleRef], - ) + React.useEffect(() => { + stableOnDrag.current = onDrag + }, [onDrag]) - /** - * Pointer up ends a drag operation - * Prevents default to avoid unwanted selection behavior - */ - const handlePointerUp = React.useCallback( - (event: React.PointerEvent) => { - if (!isDragging(handleRef.current)) return + React.useEffect(() => { + stableOnDragEnd.current = onDragEnd + }, [onDragEnd]) + + React.useEffect(() => { + function handleDrag(event: MouseEvent) { + stableOnDrag.current?.(event.movementX, false) event.preventDefault() - // Cleanup will happen in onLostPointerCapture - }, - [handleRef], - ) + } - /** - * Lost pointer capture ends a drag operation - * Cleans up dragging state - * Calls onDragEnd callback - */ - const handleLostPointerCapture = React.useCallback( - (event: React.PointerEvent) => { - if (!isDragging(handleRef.current)) return - const target = event.currentTarget - target.removeAttribute(DATA_DRAGGING_ATTR) - stableOnDragEnd.current() - }, - [handleRef], - ) + function handleDragEnd(event: MouseEvent) { + setIsDragging(false) + stableOnDragEnd.current?.() + event.preventDefault() + } - /** - * Keyboard handling for accessibility - * Arrow keys adjust the pane size in 3px increments - * Prevents default scrolling behavior - * Sets and clears dragging state via data attribute - * Calls onDrag - */ - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if ( - event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || - event.key === 'ArrowUp' || - event.key === 'ArrowDown' - ) { - event.preventDefault() - - if (!paneRef.current) return - - // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 - const delta = event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -3 : 3 - - event.currentTarget.setAttribute(DATA_DRAGGING_ATTR, 'true') - stableOnDrag.current(delta, true) + 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) { + delta = -3 + } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { + delta = 3 + } else { + return } - }, - [paneRef], - ) + setCurrentWidth(currentWidth + delta) + stableOnDrag.current?.(delta, true) + event.preventDefault() + } - const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => { - if ( - event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || - event.key === 'ArrowUp' || - event.key === 'ArrowDown' - ) { + function handleKeyDragEnd(event: KeyboardEvent) { + setIsKeyboardDrag(false) + stableOnDragEnd.current?.() event.preventDefault() - event.currentTarget.removeAttribute(DATA_DRAGGING_ATTR) - stableOnDragEnd.current() } - }, []) + // TODO: Support touch events + if (isDragging || isKeyboardDrag) { + window.addEventListener('mousemove', handleDrag) + window.addEventListener('keydown', handleKeyDrag) + window.addEventListener('mouseup', handleDragEnd) + window.addEventListener('keyup', handleKeyDragEnd) + const body = document.body as HTMLElement | undefined + body?.setAttribute('data-page-layout-dragging', 'true') + } else { + window.removeEventListener('mousemove', handleDrag) + window.removeEventListener('mouseup', handleDragEnd) + window.removeEventListener('keydown', handleKeyDrag) + window.removeEventListener('keyup', handleKeyDragEnd) + const body = document.body as HTMLElement | undefined + body?.removeAttribute('data-page-layout-dragging') + } + + return () => { + window.removeEventListener('mousemove', handleDrag) + window.removeEventListener('mouseup', handleDragEnd) + window.removeEventListener('keydown', handleKeyDrag) + window.removeEventListener('keyup', handleKeyDragEnd) + const body = document.body as HTMLElement | undefined + body?.removeAttribute('data-page-layout-dragging') + } + }, [isDragging, isKeyboardDrag, currentWidth, minWidth, maxWidth]) return (
{draggable ? ( - // Drag handle - ARIA attributes set via DOM manipulation for performance + // Drag handle
{ + if (event.button === 0) { + setIsDragging(true) + onDragStart?.() + } + }} + onKeyDown={(event: React.KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown' + ) { + setIsKeyboardDrag(true) + onDragStart?.() + } + }} onDoubleClick={onDoubleClick} /> ) : null} @@ -568,15 +490,6 @@ 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 /** @@ -685,65 +598,40 @@ const Pane = React.forwardRef { + if (isPaneWidth(width)) { + return defaultPaneWidth[width] + } else if (isCustomWidthOptions(width)) { + return Number(width.default.split('px')[0]) + } + return 0 + } - // Track current width during drag - initialized lazily in layout effect - const currentWidthRef = React.useRef(defaultWidth) + const [paneWidth, setPaneWidth] = React.useState(() => { + if (!canUseDOM) { + return getDefaultPaneWidth(width) + } - // Track whether we've initialized the width from localStorage - const initializedRef = React.useRef(false) + let storedWidth - useIsomorphicLayoutEffect(() => { - // 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 + storedWidth = localStorage.getItem(widthStorageKey) + } catch (_error) { + storedWidth = null } - 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() + return storedWidth && !isNaN(Number(storedWidth)) ? Number(storedWidth) : getDefaultPaneWidth(width) + }) - // Calculate min width constraint from width configuration - const minPaneWidth = isCustomWidthOptions(width) ? parseInt(width.min, 10) : minWidth + const updatePaneWidth = (width: number) => { + setPaneWidth(width) - // 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 + try { + localStorage.setItem(widthStorageKey, width.toString()) + } catch (_error) { + // Ignore errors } - }, [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!, - }) - }, [minPaneWidth, viewportWidth]) + } useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -767,14 +655,6 @@ const Pane = React.forwardRef { - try { - localStorage.setItem(widthStorageKey, value.toString()) - } catch { - // Ignore write errors - } - } - return (
@@ -837,55 +717,25 @@ const Pane = React.forwardRef { - const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta - const maxWidth = maxPaneWidthRef.current - + // Get the number of pixels the divider was dragged + let deltaWithDirection 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}) - } + deltaWithDirection = delta } 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}) - } - } + deltaWithDirection = position === 'end' ? -delta : delta } + updatePaneWidth(paneWidth + deltaWithDirection) }} - // Save final width to localStorage (skip React state update to avoid reconciliation) + // Ensure `paneWidth` state and actual pane width are in sync when the drag ends 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!) + const paneRect = paneRef.current?.getBoundingClientRect() + if (!paneRect) return + updatePaneWidth(paneRect.width) }} 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) - }} + onDoubleClick={() => updatePaneWidth(getDefaultPaneWidth(width))} className={classes.PaneVerticalDivider} style={ { diff --git a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap index 3fd829d183b..5ac82e6ca02 100644 --- a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap +++ b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap @@ -57,7 +57,7 @@ exports[`PageLayout > renders condensed layout 1`] = ` />
Pane
@@ -149,7 +149,7 @@ exports[`PageLayout > renders default layout 1`] = ` />
Pane
@@ -243,7 +243,7 @@ exports[`PageLayout > renders pane in different position when narrow 1`] = ` />
Pane
@@ -337,7 +337,7 @@ exports[`PageLayout > renders with dividers 1`] = ` />
Pane