diff --git a/.changeset/shiny-buckets-study.md b/.changeset/shiny-buckets-study.md new file mode 100644 index 00000000000..55c239e77f8 --- /dev/null +++ b/.changeset/shiny-buckets-study.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +reapplies PageLayout resizable enhancements without INP drop from expensive selectors 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..079d34f6cff 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -1,13 +1,9 @@ -/* 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; +/* 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 { @@ -31,6 +27,7 @@ body[data-page-layout-dragging='true'] * { --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) { @@ -45,6 +42,7 @@ body[data-page-layout-dragging='true'] * { --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; } @@ -383,6 +381,26 @@ body[data-page-layout-dragging='true'] * { } } +/** + * OPTIMIZATION: Aggressive containment during drag for ContentWrapper + * data-dragging is set on PageLayoutContent by JavaScript + * This avoids expensive :has() selectors + */ +.PageLayoutContent[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%; @@ -409,6 +427,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[data-dragging='true'] .Content { + /* Full containment (without size) - isolate from layout recalculations */ + contain: layout style paint; +} + .PaneWrapper { display: flex; width: 100%; @@ -593,11 +621,36 @@ body[data-page-layout-dragging='true'] * { width: 100%; @media screen and (min-width: 768px) { + /* + * --pane-max-width is set by JS on mount and updated on resize (debounced). + * JS calculates viewport - margin to avoid scrollbar discrepancy with 100vw. + */ width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width)); } } } +/** + * OPTIMIZATION: Performance enhancements for Pane during drag + * data-dragging is set on PageLayoutContent by JavaScript + * This avoids expensive :has() selectors + */ +.PageLayoutContent[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 +749,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 +773,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..ad7f81d6046 --- /dev/null +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -0,0 +1,537 @@ +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', + component: PageLayout, +} + +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) +// ============================================================================ + +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 + + +
+
+ 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.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 29afbe4477a..901db1c5120 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -5,13 +5,21 @@ 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 { + usePaneWidth, + updateAriaValues, + isCustomWidthOptions, + isPaneWidth, + ARROW_KEY_STEP, + type CustomWidthOptions, + type PaneWidth, +} from './usePaneWidth' const REGION_ORDER = { header: 0, @@ -33,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}, }) // ---------------------------------------------------------------------------- @@ -77,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}) @@ -86,8 +97,9 @@ const Root: React.FC> = ({ rowGap, columnGap, paneRef, + contentRef, } - }, [padding, rowGap, columnGap, paneRef]) + }, [padding, rowGap, columnGap, paneRef, contentRef]) return ( @@ -102,7 +114,9 @@ const Root: React.FC> = ({ >
{slots.header} -
{rest}
+
+ {rest} +
{slots.footer}
@@ -145,160 +159,215 @@ const HorizontalDivider: React.FC> = ({ ) } -type DraggableDividerProps = { +type VerticalDividerProps = DividerProps & { draggable?: boolean - onDragStart?: () => void - onDrag?: (delta: number, isKeyboard: boolean) => void - onDragEnd?: () => void - onDoubleClick?: () => void } -const VerticalDivider: React.FC> = ({ +const VerticalDivider: React.FC> = ({ variant = 'none', - draggable = false, - onDragStart, - onDrag, - onDragEnd, - onDoubleClick, position, className, style, + children, }) => { - const [isDragging, setIsDragging] = React.useState(false) - const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) - - const stableOnDrag = React.useRef(onDrag) - const stableOnDragEnd = React.useRef(onDragEnd) - - const {paneRef} = React.useContext(PageLayoutContext) + return ( +
+ {children} +
+ ) +} - const [minWidth, setMinWidth] = React.useState(0) - const [maxWidth, setMaxWidth] = React.useState(0) - const [currentWidth, setCurrentWidth] = React.useState(0) +type DragHandleProps = { + /** Ref for imperative ARIA updates during drag */ + handleRef: React.RefObject + /** Called once when drag starts with initial cursor X position */ + onDragStart: (clientX: number) => void + /** Called on each drag tick with cursor position (pointer) or delta (keyboard) */ + onDrag: (value: number, isKeyboard: boolean) => void + /** Called when drag operation completes */ + onDragEnd: () => void + /** 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 +} - 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]) +const DATA_DRAGGING_ATTR = 'data-dragging' +const isDragging = (handle: HTMLElement | null) => { + return handle?.getAttribute(DATA_DRAGGING_ATTR) === 'true' +} +/** + * 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, + onDragStart, + onDrag, + onDragEnd, + onDoubleClick, + 'aria-valuemin': ariaValueMin, + 'aria-valuemax': ariaValueMax, + 'aria-valuenow': ariaValueNow, +}) => { + const stableOnDragStart = React.useRef(onDragStart) + const stableOnDrag = React.useRef(onDrag) + const stableOnDragEnd = React.useRef(onDragEnd) React.useEffect(() => { + stableOnDragStart.current = onDragStart stableOnDrag.current = onDrag - }, [onDrag]) - - React.useEffect(() => { stableOnDragEnd.current = onDragEnd - }, [onDragEnd]) - - React.useEffect(() => { - function handleDrag(event: MouseEvent) { - stableOnDrag.current?.(event.movementX, false) - event.preventDefault() - } + }) - function handleDragEnd(event: MouseEvent) { - setIsDragging(false) - stableOnDragEnd.current?.() - event.preventDefault() - } + const {paneRef, contentRef} = React.useContext(PageLayoutContext) - 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 + // Helper to set/remove dragging attribute on content wrapper + // This avoids expensive :has() selectors - CSS uses simple descendant selectors instead + const setDragging = React.useCallback( + (dragging: boolean) => { + if (dragging) { + handleRef.current?.setAttribute(DATA_DRAGGING_ATTR, 'true') + contentRef.current?.setAttribute(DATA_DRAGGING_ATTR, 'true') } else { - return + handleRef.current?.removeAttribute(DATA_DRAGGING_ATTR) + contentRef.current?.removeAttribute(DATA_DRAGGING_ATTR) } - setCurrentWidth(currentWidth + delta) - stableOnDrag.current?.(delta, true) + }, + [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) + stableOnDragStart.current(event.clientX) + setDragging(true) + }, + [setDragging], + ) - function handleKeyDragEnd(event: KeyboardEvent) { - setIsKeyboardDrag(false) - stableOnDragEnd.current?.() + /** + * Pointer move during drag + * Calls onDrag with absolute cursor X position + * Using absolute position avoids drift from accumulated deltas + * Prevents default to avoid unwanted selection behavior + */ + const handlePointerMove = React.useCallback( + (event: React.PointerEvent) => { + if (!isDragging(handleRef.current)) return event.preventDefault() - } - // 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]) + stableOnDrag.current(event.clientX, false) + }, + [handleRef], + ) + + /** + * 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], + ) + + /** + * Lost pointer capture ends a drag operation + * Cleans up dragging state + * Calls onDragEnd callback + */ + const handleLostPointerCapture = React.useCallback(() => { + if (!isDragging(handleRef.current)) return + setDragging(false) + stableOnDragEnd.current() + }, [handleRef, setDragging]) + + /** + * 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' ? -ARROW_KEY_STEP : ARROW_KEY_STEP + + setDragging(true) + stableOnDrag.current(delta, true) + } + }, + [paneRef, setDragging], + ) + + 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 -
{ - 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} -
+ 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} + /> ) } @@ -471,25 +540,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) -} - export type PageLayoutPaneProps = { position?: keyof typeof panePositions | ResponsiveValue /** @@ -544,15 +594,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>( @@ -598,40 +639,25 @@ const Pane = React.forwardRef { - if (isPaneWidth(width)) { - return defaultPaneWidth[width] - } else if (isCustomWidthOptions(width)) { - return Number(width.default.split('px')[0]) - } - return 0 - } - - const [paneWidth, setPaneWidth] = React.useState(() => { - if (!canUseDOM) { - return getDefaultPaneWidth(width) - } - - let storedWidth - - try { - storedWidth = localStorage.getItem(widthStorageKey) - } catch (_error) { - storedWidth = null - } - - return storedWidth && !isNaN(Number(storedWidth)) ? Number(storedWidth) : getDefaultPaneWidth(width) - }) - - const updatePaneWidth = (width: number) => { - setPaneWidth(width) - - try { - localStorage.setItem(widthStorageKey, width.toString()) - } catch (_error) { - // Ignore errors - } - } + // Ref to the drag handle for updating ARIA attributes + const handleRef = React.useRef(null) + + // Cache drag start values to calculate relative delta during drag + // This approach is immune to layout shifts (scrollbars appearing/disappearing) + const dragStartClientXRef = React.useRef(0) + const dragStartWidthRef = React.useRef(0) + // Cache max width at drag start - won't change during a drag operation + const dragMaxWidthRef = React.useRef(0) + + const {currentWidth, currentWidthRef, minPaneWidth, maxPaneWidth, getMaxPaneWidth, saveWidth, getDefaultWidth} = + usePaneWidth({ + width, + minWidth, + resizable, + widthStorageKey, + paneRef, + handleRef, + }) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -683,6 +709,10 @@ const Pane = React.forwardRef
@@ -717,32 +747,79 @@ const Pane = React.forwardRef { - // Get the number of pixels the divider was dragged - let deltaWithDirection - if (isKeyboard) { - deltaWithDirection = delta - } else { - deltaWithDirection = position === 'end' ? -delta : delta - } - updatePaneWidth(paneWidth + deltaWithDirection) - }} - // Ensure `paneWidth` state and actual pane width are in sync when the drag ends - onDragEnd={() => { - const paneRect = paneRef.current?.getBoundingClientRect() - if (!paneRect) return - updatePaneWidth(paneRect.width) - }} position={positionProp} - // Reset pane width on double click - onDoubleClick={() => updatePaneWidth(getDefaultPaneWidth(width))} className={classes.PaneVerticalDivider} style={ { '--spacing': `var(--spacing-${columnGap})`, } as React.CSSProperties } - /> + > + {resizable ? ( + { + // Cache cursor position and pane width at drag start + // Using relative delta (current - start) is immune to layout shifts + // (e.g., scrollbars appearing/disappearing during drag) + dragStartClientXRef.current = clientX + dragStartWidthRef.current = paneRef.current?.getBoundingClientRect().width ?? currentWidthRef.current! + // Cache max width - won't change during drag + dragMaxWidthRef.current = getMaxPaneWidth() + }} + onDrag={(value, isKeyboard) => { + // Use cached max width for pointer drag, fresh value for keyboard (less frequent) + const maxWidth = isKeyboard ? getMaxPaneWidth() : dragMaxWidthRef.current + + if (isKeyboard) { + // Keyboard: value is a delta (e.g., +3 or -3) + const delta = value + const newWidth = Math.max(minPaneWidth, Math.min(maxWidth, currentWidthRef.current! + delta)) + if (newWidth !== currentWidthRef.current) { + currentWidthRef.current = newWidth + paneRef.current?.style.setProperty('--pane-width', `${newWidth}px`) + updateAriaValues(handleRef.current, {current: newWidth, max: maxWidth}) + } + } else { + // Pointer: value is clientX - calculate width using relative delta from drag start + // This approach is immune to layout shifts during drag + if (paneRef.current) { + const deltaX = value - dragStartClientXRef.current + // For position='end': cursor moving left (negative delta) increases width + // For position='start': cursor moving right (positive delta) increases width + const directedDelta = position === 'end' ? -deltaX : deltaX + const newWidth = dragStartWidthRef.current + directedDelta + + const clampedWidth = Math.max(minPaneWidth, Math.min(maxWidth, newWidth)) + + // Only update if width actually changed + if (Math.round(clampedWidth) !== Math.round(currentWidthRef.current!)) { + paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) + currentWidthRef.current = clampedWidth + updateAriaValues(handleRef.current, {current: Math.round(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..a529d0b7367 --- /dev/null +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -0,0 +1,771 @@ +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 add debounced resize listener for preset widths', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener') + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + // Adds resize listener for throttled CSS updates and debounced state sync + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + addEventListenerSpy.mockRestore() + }) + + it('should not add resize listener for custom widths (fixed max)', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener') + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '300px', max: '400px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test-custom', + ...refs, + }), + ) + + // Custom widths have fixed max - no need for resize listener + expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function), expect.anything()) + addEventListenerSpy.mockRestore() + }) + + it('should clamp ref when viewport shrinks', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-clamp', + ...refs, + }), + ) + + // Set current width to max + const initialMax = result.current.getMaxPaneWidth() // 1280 - 511 = 769 + result.current.currentWidthRef.current = initialMax + + // Shrink viewport + vi.stubGlobal('innerWidth', 800) + + // Wrap resize + debounce in act() since it triggers startTransition state update + await act(async () => { + window.dispatchEvent(new Event('resize')) + await vi.advanceTimersByTimeAsync(150) + }) + + // getMaxPaneWidth now returns 800 - 511 = 289 + expect(result.current.getMaxPaneWidth()).toBe(289) + // ref should be clamped after resize handler fires + expect(result.current.currentWidthRef.current).toBe(289) + + vi.useRealTimers() + }) + + it('should update CSS variable immediately via throttle', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-css-throttle', + ...refs, + }), + ) + + // Initial --pane-max-width should be set on mount + expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('769px') + + // Shrink viewport + vi.stubGlobal('innerWidth', 1000) + + // Fire resize - CSS should update immediately (throttled at 16ms) + window.dispatchEvent(new Event('resize')) + + // CSS variable should be updated immediately: 1000 - 511 = 489 + expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('489px') + + vi.useRealTimers() + }) + + it('should update ARIA attributes after debounce', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-aria-debounce', + ...refs, + }), + ) + + // Initial ARIA max should be set on mount + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') + + // Shrink viewport + vi.stubGlobal('innerWidth', 900) + + // Fire resize but don't wait for debounce + window.dispatchEvent(new Event('resize')) + await vi.advanceTimersByTimeAsync(50) + + // ARIA should NOT be updated yet + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') + + // Wait for debounce + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) + + // ARIA should now be updated: 900 - 511 = 389 + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389') + + vi.useRealTimers() + }) + + it('should throttle CSS updates and debounce full sync on rapid resize', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const setPropertySpy = vi.spyOn(refs.paneRef.current!.style, 'setProperty') + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-throttle-debounce', + ...refs, + }), + ) + + // Clear mount calls + setPropertySpy.mockClear() + + // Fire resize - first one updates CSS immediately + vi.stubGlobal('innerWidth', 1100) + window.dispatchEvent(new Event('resize')) + + // CSS should update immediately (first call, throttle allows) + expect(setPropertySpy).toHaveBeenCalledWith('--pane-max-width', '589px') // 1100 - 511 + + setPropertySpy.mockClear() + + // Fire more resize events rapidly (within throttle window) + for (let i = 0; i < 3; i++) { + vi.stubGlobal('innerWidth', 1000 - i * 50) + window.dispatchEvent(new Event('resize')) + } + + // Throttle limits calls - may have scheduled RAF but not executed yet + // Advance past throttle window to let RAF execute + await vi.advanceTimersByTimeAsync(20) + + // Should have at least one more CSS update from RAF + expect(setPropertySpy).toHaveBeenCalled() + + // But ARIA should not be updated yet (debounced) + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') // Still initial + + // Wait for debounce to complete + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + + // Now ARIA and refs are synced + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389') // 900 - 511 + + vi.useRealTimers() + }) + + it('should update React state via startTransition after debounce', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-state-transition', + ...refs, + }), + ) + + // Initial maxPaneWidth state + expect(result.current.maxPaneWidth).toBe(769) + + // Shrink viewport + vi.stubGlobal('innerWidth', 800) + window.dispatchEvent(new Event('resize')) + + // Before debounce completes, state unchanged + await vi.advanceTimersByTimeAsync(50) + expect(result.current.maxPaneWidth).toBe(769) + + // After debounce, state updated via startTransition + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) + + // State now reflects new max: 800 - 511 = 289 + expect(result.current.maxPaneWidth).toBe(289) + + vi.useRealTimers() + }) + + it('should cleanup resize listener on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame') + const refs = createMockRefs() + + const {unmount} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + // cancelAnimationFrame called with null is fine (no pending RAF) + removeEventListenerSpy.mockRestore() + cancelAnimationFrameSpy.mockRestore() + }) + + 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-not-resizable', + ...refs, + }), + ) + + expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function)) + addEventListenerSpy.mockRestore() + }) + }) + + describe('on-demand max calculation', () => { + it('should calculate max dynamically based on current viewport', () => { + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-dynamic', + ...refs, + }), + ) + + // Initial max at 1280px: 1280 - 511 = 769 + expect(result.current.getMaxPaneWidth()).toBe(769) + + // Viewport changes (no resize event needed) + vi.stubGlobal('innerWidth', 800) + + // getMaxPaneWidth reads window.innerWidth dynamically + expect(result.current.getMaxPaneWidth()).toBe(289) + }) + + it('should return custom max regardless of viewport for custom widths', () => { + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '300px', max: '400px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test-custom', + ...refs, + }), + ) + + expect(result.current.getMaxPaneWidth()).toBe(400) + + // Viewport changes don't affect custom max + vi.stubGlobal('innerWidth', 500) + expect(result.current.getMaxPaneWidth()).toBe(400) + }) + }) +}) + +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..2449901b0a0 --- /dev/null +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -0,0 +1,323 @@ +import React, {startTransition} from 'react' +import {canUseDOM} from '../utils/environment' +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. + // + // NOTE: We read from localStorage during initial state to avoid a visible resize flicker + // when the stored width differs from the default. This causes a React hydration mismatch + // (server renders default width, client renders stored width), but we handle this with + // suppressHydrationWarning on the Pane element. The mismatch only affects the --pane-width + // CSS variable, not DOM structure or children. + const [currentWidth, setCurrentWidth] = React.useState(() => { + const defaultWidth = getDefaultPaneWidth(width) + + if (!resizable || !canUseDOM) { + return defaultWidth + } + + try { + const storedWidth = localStorage.getItem(widthStorageKey) + if (storedWidth !== null) { + const parsed = Number(storedWidth) + if (!isNaN(parsed) && parsed > 0) { + return parsed + } + } + } catch { + // localStorage unavailable - keep default + } + + return defaultWidth + }) + // 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 --- + // Stable ref to getMaxPaneWidth for use in resize handler without re-subscribing + const getMaxPaneWidthRef = React.useRef(getMaxPaneWidth) + useIsomorphicLayoutEffect(() => { + getMaxPaneWidthRef.current = getMaxPaneWidth + }) + + // Update CSS variable, refs, and ARIA on mount and window resize. + // Strategy: + // 1. Throttled (16ms): Update --pane-max-width CSS variable for immediate visual clamp + // 2. Debounced (150ms): Sync refs, ARIA, and React state when resize stops + useIsomorphicLayoutEffect(() => { + if (!resizable) return + + let lastViewportWidth = window.innerWidth + + // Quick CSS-only update for immediate visual feedback (throttled) + const updateCSSOnly = () => { + const actualMax = getMaxPaneWidthRef.current() + paneRef.current?.style.setProperty('--pane-max-width', `${actualMax}px`) + } + + // Full sync of refs, ARIA, and state (debounced, runs when resize stops) + const syncAll = () => { + const currentViewportWidth = window.innerWidth + + // Only call getComputedStyle if we crossed the breakpoint (expensive) + 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 + + if (crossedBreakpoint) { + maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) + } + + const actualMax = getMaxPaneWidthRef.current() + + // Update CSS variable for visual clamping (may already be set by throttled update) + paneRef.current?.style.setProperty('--pane-max-width', `${actualMax}px`) + + // Track if we clamped current width + const wasClamped = currentWidthRef.current > actualMax + if (wasClamped) { + currentWidthRef.current = actualMax + paneRef.current?.style.setProperty('--pane-width', `${actualMax}px`) + } + + // Update ARIA via DOM - cheap, no React re-render + updateAriaValues(handleRef.current, {max: actualMax, current: currentWidthRef.current}) + + // Defer state updates so parent re-renders see accurate values + startTransition(() => { + setMaxPaneWidth(actualMax) + if (wasClamped) { + setCurrentWidth(actualMax) + } + }) + } + + // Initial calculation on mount + maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) + const initialMax = getMaxPaneWidthRef.current() + setMaxPaneWidth(initialMax) + paneRef.current?.style.setProperty('--pane-max-width', `${initialMax}px`) + updateAriaValues(handleRef.current, {min: minPaneWidth, max: initialMax, current: currentWidthRef.current}) + + // For custom widths, max is fixed - no need to listen to resize + if (customMaxWidth !== null) return + + // Throttle CSS updates (16ms ≈ 60fps), debounce full sync (150ms) + const THROTTLE_MS = 16 + const DEBOUNCE_MS = 150 + let rafId: number | null = null + let debounceId: ReturnType | null = null + let lastThrottleTime = 0 + + const handleResize = () => { + const now = Date.now() + + // Throttled CSS update for immediate visual feedback + if (now - lastThrottleTime >= THROTTLE_MS) { + lastThrottleTime = now + updateCSSOnly() + } else if (rafId === null) { + // Schedule next frame if we're within throttle window + rafId = requestAnimationFrame(() => { + rafId = null + lastThrottleTime = Date.now() + updateCSSOnly() + }) + } + + // Debounced full sync (refs, ARIA, state) when resize stops + if (debounceId !== null) { + clearTimeout(debounceId) + } + debounceId = setTimeout(() => { + debounceId = null + syncAll() + }, DEBOUNCE_MS) + } + + // 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 (rafId !== null) cancelAnimationFrame(rafId) + if (debounceId !== null) clearTimeout(debounceId) + window.removeEventListener('resize', handleResize) + } + }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef]) + + return { + currentWidth, + currentWidthRef, + minPaneWidth, + maxPaneWidth, + getMaxPaneWidth, + saveWidth, + getDefaultWidth, + } +}