diff --git a/.changeset/olive-heads-enter.md b/.changeset/olive-heads-enter.md new file mode 100644 index 00000000000..e95379932a0 --- /dev/null +++ b/.changeset/olive-heads-enter.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Improve drag performance for PageLayout diff --git a/e2e/components/Axe.test.ts b/e2e/components/Axe.test.ts index 74b17d88f6b..714ac97c6f0 100644 --- a/e2e/components/Axe.test.ts +++ b/e2e/components/Axe.test.ts @@ -14,6 +14,8 @@ 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 5c3041059ea..02dc721bae0 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -1,15 +1,3 @@ -/* 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; @@ -357,6 +345,12 @@ body[data-page-layout-dragging='true'] * { 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; } @@ -383,6 +377,26 @@ body[data-page-layout-dragging='true'] * { } } +/** + * 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%; @@ -392,6 +406,14 @@ body[data-page-layout-dragging='true'] * { 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; } @@ -409,6 +431,16 @@ body[data-page-layout-dragging='true'] * { } } +/** + * 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%; @@ -585,6 +617,15 @@ body[data-page-layout-dragging='true'] * { /* 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; } @@ -598,6 +639,26 @@ body[data-page-layout-dragging='true'] * { } } +/** + * 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 */ @@ -696,12 +757,22 @@ body[data-page-layout-dragging='true'] * { 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 { @@ -710,6 +781,7 @@ body[data-page-layout-dragging='true'] * { .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 new file mode 100644 index 00000000000..b1b887999ba --- /dev/null +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -0,0 +1,492 @@ +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 29afbe4477a..3aafaf85b22 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -5,13 +5,68 @@ 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, @@ -147,16 +202,33 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean - onDragStart?: () => void - onDrag?: (delta: number, isKeyboard: boolean) => void - onDragEnd?: () => void - onDoubleClick?: () => void + 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' } const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, - onDragStart, + handleRef, onDrag, onDragEnd, onDoubleClick, @@ -164,100 +236,114 @@ 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) - const [minWidth, setMinWidth] = React.useState(0) - const [maxWidth, setMaxWidth] = React.useState(0) - const [currentWidth, setCurrentWidth] = React.useState(0) - - 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]) + /** + * 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') + }, []) - React.useEffect(() => { - stableOnDrag.current = onDrag - }, [onDrag]) + /** + * 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(() => { - stableOnDragEnd.current = onDragEnd - }, [onDragEnd]) + if (event.movementX !== 0) { + stableOnDrag.current(event.movementX, false) + } + }, + [handleRef], + ) - React.useEffect(() => { - function handleDrag(event: MouseEvent) { - stableOnDrag.current?.(event.movementX, false) + /** + * 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 event.preventDefault() - } + // Cleanup will happen in onLostPointerCapture + }, + [handleRef], + ) - function handleDragEnd(event: MouseEvent) { - setIsDragging(false) - stableOnDragEnd.current?.() - event.preventDefault() - } + /** + * 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 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 + /** + * 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) } - setCurrentWidth(currentWidth + delta) - stableOnDrag.current?.(delta, true) - event.preventDefault() - } + }, + [paneRef], + ) - function handleKeyDragEnd(event: KeyboardEvent) { - setIsKeyboardDrag(false) - stableOnDragEnd.current?.() + const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown' + ) { 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 + // Drag handle - ARIA attributes set via DOM manipulation for performance
{ - 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?.() - } - }} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onLostPointerCapture={handleLostPointerCapture} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} onDoubleClick={onDoubleClick} /> ) : null} @@ -490,6 +568,15 @@ 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 /** @@ -598,40 +685,65 @@ const Pane = React.forwardRef { - if (isPaneWidth(width)) { - return defaultPaneWidth[width] - } else if (isCustomWidthOptions(width)) { - return Number(width.default.split('px')[0]) - } - return 0 - } + // Initial pane width for the first render - only used to set the initial CSS variable. + // After mount, all updates go directly to the DOM via style.setProperty() to avoid re-renders. + const defaultWidth = getDefaultPaneWidth(width) - const [paneWidth, setPaneWidth] = React.useState(() => { - if (!canUseDOM) { - return getDefaultPaneWidth(width) - } + // Track current width during drag - initialized lazily in layout effect + const currentWidthRef = React.useRef(defaultWidth) - let storedWidth + // Track whether we've initialized the width from localStorage + const initializedRef = React.useRef(false) + 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 { - storedWidth = localStorage.getItem(widthStorageKey) - } catch (_error) { - storedWidth = null + 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]) - return storedWidth && !isNaN(Number(storedWidth)) ? Number(storedWidth) : getDefaultPaneWidth(width) - }) + // Subscribe to viewport width changes for responsive max constraint calculation + const viewportWidth = useViewportWidth() - const updatePaneWidth = (width: number) => { - setPaneWidth(width) + // Calculate min width constraint from width configuration + const minPaneWidth = isCustomWidthOptions(width) ? parseInt(width.min, 10) : minWidth - try { - localStorage.setItem(widthStorageKey, width.toString()) - } catch (_error) { - // Ignore errors + // 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!, + }) + }, [minPaneWidth, viewportWidth]) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -655,6 +767,14 @@ const Pane = React.forwardRef { + try { + localStorage.setItem(widthStorageKey, value.toString()) + } catch { + // Ignore write errors + } + } + return (
@@ -717,25 +837,55 @@ const Pane = React.forwardRef { - // Get the number of pixels the divider was dragged - let deltaWithDirection + const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta + const maxWidth = maxPaneWidthRef.current + if (isKeyboard) { - deltaWithDirection = delta + // 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 { - deltaWithDirection = position === 'end' ? -delta : delta + // 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}) + } + } } - updatePaneWidth(paneWidth + deltaWithDirection) }} - // Ensure `paneWidth` state and actual pane width are in sync when the drag ends + // Save final width to localStorage (skip React state update to avoid reconciliation) onDragEnd={() => { - const paneRect = paneRef.current?.getBoundingClientRect() - if (!paneRect) return - updatePaneWidth(paneRect.width) + // 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={() => updatePaneWidth(getDefaultPaneWidth(width))} + 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={ { diff --git a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap index 5ac82e6ca02..3fd829d183b 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