diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 5063e6abaf7..dd46fd0fcca 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -1,3 +1,11 @@ +/* Exported values for JavaScript consumption */ +:export { + /* Breakpoint where --pane-max-width-diff changes (used in usePaneWidth.ts) */ + paneMaxWidthDiffBreakpoint: 1280; + /* Default value for --pane-max-width-diff below the breakpoint */ + paneMaxWidthDiffDefault: 511; +} + .PageLayoutRoot { /* Region Order */ --region-order-header: 0; @@ -19,6 +27,7 @@ --pane-width-small: 100%; --pane-width-medium: 100%; --pane-width-large: 100%; + /* NOTE: This value is exported via :export for use in usePaneWidth.ts */ --pane-max-width-diff: 511px; @media screen and (min-width: 768px) { @@ -33,6 +42,7 @@ --pane-width-large: 320px; } + /* NOTE: This breakpoint value is exported via :export for use in usePaneWidth.ts */ @media screen and (min-width: 1280px) { --pane-max-width-diff: 959px; } 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 56c6c729d13..49d2f9cfb8c 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -11,36 +11,15 @@ import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes import classes from './PageLayout.module.css' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types' -import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' - -/** - * Default value for --pane-max-width-diff CSS variable. - * This is the fallback when the element isn't mounted or value can't be read. - */ -const DEFAULT_MAX_WIDTH_DIFF = 511 - -/** - * Pixel increment for keyboard arrow key resizing. - * @see https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 - */ -const ARROW_KEY_STEP = 3 - -/** - * Default max pane width for SSR when viewport is unknown. - * Updated to actual value in layout effect before paint. - */ -const SSR_DEFAULT_MAX_WIDTH = 600 - -/** - * 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. - */ -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 -} +import { + usePaneWidth, + updateAriaValues, + isCustomWidthOptions, + isPaneWidth, + ARROW_KEY_STEP, + type CustomWidthOptions, + type PaneWidth, +} from './usePaneWidth' const REGION_ORDER = { header: 0, @@ -220,18 +199,6 @@ type DragHandleProps = { 'aria-valuenow'?: number } -// 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' @@ -568,34 +535,6 @@ Content.displayName = 'PageLayout.Content' // ---------------------------------------------------------------------------- // PageLayout.Pane -type Measurement = `${number}px` - -type CustomWidthOptions = { - min: Measurement - default: Measurement - max: Measurement -} - -type PaneWidth = keyof typeof paneWidths - -const isCustomWidthOptions = (width: PaneWidth | CustomWidthOptions): width is CustomWidthOptions => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return (width as CustomWidthOptions).default !== undefined -} - -const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => { - return ['small', 'medium', 'large'].includes(width as PaneWidth) -} - -const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => { - if (isPaneWidth(w)) { - return defaultPaneWidth[w] - } else if (isCustomWidthOptions(w)) { - return parseInt(w.default, 10) - } - return 0 -} - export type PageLayoutPaneProps = { position?: keyof typeof panePositions | ResponsiveValue /** @@ -650,15 +589,6 @@ const panePositions = { end: REGION_ORDER.paneEnd, } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const paneWidths = { - small: ['100%', null, '240px', '256px'], - medium: ['100%', null, '256px', '296px'], - large: ['100%', null, '256px', '320px'], -} - -const defaultPaneWidth = {small: 256, medium: 296, large: 320} - const overflowProps = {tabIndex: 0, role: 'region'} const Pane = React.forwardRef>( @@ -704,105 +634,18 @@ const Pane = React.forwardRef getDefaultPaneWidth(width)) - - // Track current width during drag - initialized lazily in layout effect - const currentWidthRef = React.useRef(defaultWidth) - - // 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 { - 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`) - setDefaultWidth(num) - return - } - } catch { - // localStorage unavailable - keep default - } - }, [widthStorageKey, paneRef, resizable]) - - // Calculate min width constraint from width configuration - const minPaneWidth = isCustomWidthOptions(width) ? parseInt(width.min, 10) : minWidth - - // 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 using cached maxWidthDiff - const getMaxPaneWidth = React.useCallback(() => { - if (isCustomWidthOptions(width)) { - return parseInt(width.max, 10) - } - const viewportWidth = window.innerWidth - return viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth - }, [width, minPaneWidth]) - - // Max pane width for React state - SSR uses a sensible default, updated on mount - // This is used for ARIA attributes in JSX to ensure SSR accessibility - const getInitialMaxPaneWidth = () => { - if (isCustomWidthOptions(width)) { - return parseInt(width.max, 10) - } - // SSR-safe default: use a reasonable max based on typical viewport - // This will be updated in layout effect before paint - return SSR_DEFAULT_MAX_WIDTH - } - const [maxPaneWidth, setMaxPaneWidth] = React.useState(getInitialMaxPaneWidth) - // Ref to the drag handle for updating ARIA attributes const handleRef = React.useRef(null) - const getMaxPaneWidthRef = React.useRef(getMaxPaneWidth) - useIsomorphicLayoutEffect(() => { - getMaxPaneWidthRef.current = getMaxPaneWidth - }) - // Update max pane width on mount and window resize for accurate ARIA values - // Window resize events only fire on actual viewport changes (not content changes), - // so this doesn't cause the INP issues that ResizeObserver on document.documentElement did - useIsomorphicLayoutEffect(() => { - const updateMax = () => { - // Update cached CSS variable value (only getComputedStyle call happens here, not during drag) - maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) - const actualMax = getMaxPaneWidthRef.current() - setMaxPaneWidth(actualMax) - // Clamp current width if it exceeds new max (viewport shrunk) - if (currentWidthRef.current > actualMax) { - currentWidthRef.current = actualMax - paneRef.current?.style.setProperty('--pane-width', `${actualMax}px`) - setDefaultWidth(actualMax) - } - updateAriaValues(handleRef.current, {min: minPaneWidth, max: actualMax, current: currentWidthRef.current}) - } - updateMax() - - // Throttle resize handler to animation frames - let rafId: number | null = null - const throttledUpdateMax = () => { - if (rafId) cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(updateMax) - } - - // ResizeObserver on document.documentElement fires on any content change (typing, etc), - // causing INP regressions. Window resize only fires on viewport changes. - // eslint-disable-next-line github/prefer-observers - window.addEventListener('resize', throttledUpdateMax) - return () => { - if (rafId) cancelAnimationFrame(rafId) - window.removeEventListener('resize', throttledUpdateMax) - } - }, [minPaneWidth, paneRef]) + const {currentWidth, currentWidthRef, minPaneWidth, maxPaneWidth, getMaxPaneWidth, saveWidth, getDefaultWidth} = + usePaneWidth({ + width, + minWidth, + resizable, + widthStorageKey, + paneRef, + handleRef, + }) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -826,16 +669,6 @@ const Pane = React.forwardRef { - setDefaultWidth(value) - try { - localStorage.setItem(widthStorageKey, value.toString()) - } catch { - // Ignore write errors - } - } - return (
@@ -913,7 +746,7 @@ const Pane = React.forwardRef { const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta const maxWidth = getMaxPaneWidth() @@ -950,7 +783,7 @@ const Pane = React.forwardRef { - const resetWidth = getDefaultPaneWidth(width) + const resetWidth = getDefaultWidth() if (paneRef.current) { paneRef.current.style.setProperty('--pane-width', `${resetWidth}px`) currentWidthRef.current = resetWidth diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts new file mode 100644 index 00000000000..4543fdfffb7 --- /dev/null +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -0,0 +1,521 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import {renderHook, act} from '@testing-library/react' +import { + usePaneWidth, + isCustomWidthOptions, + isPaneWidth, + getDefaultPaneWidth, + getPaneMaxWidthDiff, + updateAriaValues, + defaultPaneWidth, + DEFAULT_MAX_WIDTH_DIFF, + SSR_DEFAULT_MAX_WIDTH, + ARROW_KEY_STEP, +} from './usePaneWidth' + +// Mock refs for hook testing +const createMockRefs = () => ({ + paneRef: {current: document.createElement('div')} as React.RefObject, + handleRef: {current: document.createElement('div')} as React.RefObject, +}) + +describe('usePaneWidth', () => { + beforeEach(() => { + localStorage.clear() + vi.stubGlobal('innerWidth', 1280) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('initialization', () => { + it('should initialize with default width for preset size', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + + it('should initialize with custom width default', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: {min: '200px', default: '350px', max: '500px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + expect(result.current.currentWidth).toBe(350) + }) + + it('should restore width from localStorage on mount', () => { + localStorage.setItem('test-pane', '400') + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + expect(result.current.currentWidth).toBe(400) + }) + + it('should not restore from localStorage when not resizable', () => { + localStorage.setItem('test-pane', '400') + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: false, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + // Should use default, not localStorage value + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + + it('should ignore invalid localStorage values', () => { + localStorage.setItem('test-pane', 'invalid') + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + + it('should ignore zero or negative localStorage values', () => { + localStorage.setItem('test-pane', '0') + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + }) + + describe('saveWidth', () => { + it('should update state and localStorage', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-save', + ...refs, + }), + ) + + act(() => { + result.current.saveWidth(450) + }) + + expect(result.current.currentWidth).toBe(450) + expect(result.current.currentWidthRef.current).toBe(450) + expect(localStorage.getItem('test-save')).toBe('450') + }) + + it('should handle localStorage write errors gracefully', () => { + const refs = createMockRefs() + + // Mock localStorage.setItem to throw + const originalSetItem = localStorage.setItem + localStorage.setItem = vi.fn(() => { + throw new Error('QuotaExceeded') + }) + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-save', + ...refs, + }), + ) + + // Should not throw + act(() => { + result.current.saveWidth(450) + }) + + // State should still update + expect(result.current.currentWidth).toBe(450) + + localStorage.setItem = originalSetItem + }) + }) + + describe('minPaneWidth', () => { + it('should use minWidth prop for preset widths', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 200, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(result.current.minPaneWidth).toBe(200) + }) + + it('should use width.min for custom widths', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '300px', max: '500px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(result.current.minPaneWidth).toBe(150) + }) + }) + + describe('maxPaneWidth', () => { + it('should use SSR default initially for preset widths', () => { + const refs = createMockRefs() + // We need to test the initial state before effects run + // Since we're in browser environment, the effect runs immediately + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: false, // Disable to prevent effect from updating + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(result.current.maxPaneWidth).toBe(SSR_DEFAULT_MAX_WIDTH) + }) + + it('should use custom max for custom widths', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '300px', max: '500px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(result.current.maxPaneWidth).toBe(500) + }) + }) + + describe('getMaxPaneWidth', () => { + it('should return custom max for custom widths regardless of viewport', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '300px', max: '400px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(result.current.getMaxPaneWidth()).toBe(400) + + // Even if viewport changes, custom max is fixed + vi.stubGlobal('innerWidth', 500) + expect(result.current.getMaxPaneWidth()).toBe(400) + }) + + it('should calculate max based on viewport for preset widths', () => { + const refs = createMockRefs() + vi.stubGlobal('innerWidth', 1280) + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + // viewport (1280) - DEFAULT_MAX_WIDTH_DIFF (511) = 769 + expect(result.current.getMaxPaneWidth()).toBe(769) + }) + + it('should return minPaneWidth when viewport is too small', () => { + const refs = createMockRefs() + vi.stubGlobal('innerWidth', 300) // Very small viewport + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + // 300 - 511 = -211, so Math.max(256, -211) = 256 + expect(result.current.getMaxPaneWidth()).toBe(256) + }) + }) + + describe('getDefaultWidth', () => { + it('should return default for preset width', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: 'large', + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(result.current.getDefaultWidth()).toBe(defaultPaneWidth.large) + }) + + it('should return custom default for custom widths', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '275px', max: '500px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(result.current.getDefaultWidth()).toBe(275) + }) + }) + + describe('resize listener', () => { + it('should not add resize listener when not resizable', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener') + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: false, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function)) + addEventListenerSpy.mockRestore() + }) + + it('should not add resize listener for custom widths (max is fixed)', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener') + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: {min: '150px', default: '300px', max: '500px'}, + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function)) + addEventListenerSpy.mockRestore() + }) + + it('should add resize listener for preset widths when resizable', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener') + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + addEventListenerSpy.mockRestore() + }) + + it('should cleanup resize listener on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + const refs = createMockRefs() + + const {unmount} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test', + ...refs, + }), + ) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + removeEventListenerSpy.mockRestore() + }) + }) +}) + +describe('helper functions', () => { + describe('isCustomWidthOptions', () => { + it('should return true for custom width objects', () => { + expect(isCustomWidthOptions({min: '100px', default: '200px', max: '300px'})).toBe(true) + }) + + it('should return false for preset width strings', () => { + expect(isCustomWidthOptions('small')).toBe(false) + expect(isCustomWidthOptions('medium')).toBe(false) + expect(isCustomWidthOptions('large')).toBe(false) + }) + }) + + describe('isPaneWidth', () => { + it('should return true for valid preset widths', () => { + expect(isPaneWidth('small')).toBe(true) + expect(isPaneWidth('medium')).toBe(true) + expect(isPaneWidth('large')).toBe(true) + }) + + it('should return false for custom width objects', () => { + expect(isPaneWidth({min: '100px', default: '200px', max: '300px'})).toBe(false) + }) + }) + + describe('getDefaultPaneWidth', () => { + it('should return correct default for preset widths', () => { + expect(getDefaultPaneWidth('small')).toBe(defaultPaneWidth.small) + expect(getDefaultPaneWidth('medium')).toBe(defaultPaneWidth.medium) + expect(getDefaultPaneWidth('large')).toBe(defaultPaneWidth.large) + }) + + it('should parse custom width default', () => { + expect(getDefaultPaneWidth({min: '100px', default: '250px', max: '400px'})).toBe(250) + }) + }) + + describe('getPaneMaxWidthDiff', () => { + it('should return default when element is null', () => { + expect(getPaneMaxWidthDiff(null)).toBe(DEFAULT_MAX_WIDTH_DIFF) + }) + + it('should return default when CSS variable is not set', () => { + const element = document.createElement('div') + expect(getPaneMaxWidthDiff(element)).toBe(DEFAULT_MAX_WIDTH_DIFF) + }) + }) + + describe('updateAriaValues', () => { + it('should set ARIA attributes on element', () => { + const handle = document.createElement('div') + + updateAriaValues(handle, {min: 100, max: 500, current: 300}) + + expect(handle.getAttribute('aria-valuemin')).toBe('100') + expect(handle.getAttribute('aria-valuemax')).toBe('500') + expect(handle.getAttribute('aria-valuenow')).toBe('300') + expect(handle.getAttribute('aria-valuetext')).toBe('Pane width 300 pixels') + }) + + it('should handle null element gracefully', () => { + // Should not throw + expect(() => updateAriaValues(null, {min: 100, max: 500, current: 300})).not.toThrow() + }) + + it('should only update provided values', () => { + const handle = document.createElement('div') + handle.setAttribute('aria-valuemin', '50') + handle.setAttribute('aria-valuemax', '600') + + updateAriaValues(handle, {current: 300}) + + // Original values unchanged + expect(handle.getAttribute('aria-valuemin')).toBe('50') + expect(handle.getAttribute('aria-valuemax')).toBe('600') + // Updated value + expect(handle.getAttribute('aria-valuenow')).toBe('300') + }) + }) +}) + +describe('constants', () => { + it('should export expected constants', () => { + expect(DEFAULT_MAX_WIDTH_DIFF).toBe(511) + expect(SSR_DEFAULT_MAX_WIDTH).toBe(600) + expect(ARROW_KEY_STEP).toBe(3) + expect(defaultPaneWidth).toEqual({small: 256, medium: 296, large: 320}) + }) + + /** + * This test documents the CSS/JS coupling. + * The CSS variable --pane-max-width-diff changes at a breakpoint: + * - Below breakpoint: 511px (DEFAULT_MAX_WIDTH_DIFF) + * - At/above breakpoint: 959px + * + * The breakpoint value is exported from PageLayout.module.css via :export + * and imported into usePaneWidth.ts, so they stay in sync automatically. + */ + it('should have DEFAULT_MAX_WIDTH_DIFF matching CSS value below breakpoint', () => { + // This constant must match --pane-max-width-diff in PageLayout.module.css + // for viewports below the breakpoint. + expect(DEFAULT_MAX_WIDTH_DIFF).toBe(511) + }) +}) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts new file mode 100644 index 00000000000..120403e68ea --- /dev/null +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -0,0 +1,289 @@ +import React from 'react' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' +import cssExports from './PageLayout.module.css' + +// ---------------------------------------------------------------------------- +// Types + +type Measurement = `${number}px` + +export type CustomWidthOptions = { + min: Measurement + default: Measurement + max: Measurement +} + +export type PaneWidth = 'small' | 'medium' | 'large' + +export type UsePaneWidthOptions = { + width: PaneWidth | CustomWidthOptions + minWidth: number + resizable: boolean + widthStorageKey: string + paneRef: React.RefObject + handleRef: React.RefObject +} + +export type UsePaneWidthResult = { + /** Current width for React state (used in ARIA attributes) */ + currentWidth: number + /** Mutable ref tracking width during drag operations */ + currentWidthRef: React.MutableRefObject + /** Minimum allowed pane width */ + minPaneWidth: number + /** Maximum allowed pane width (updates on viewport resize) */ + maxPaneWidth: number + /** Calculate current max width constraint */ + getMaxPaneWidth: () => number + /** Persist width to localStorage and sync React state */ + saveWidth: (value: number) => void + /** Reset to default width */ + getDefaultWidth: () => number +} + +// ---------------------------------------------------------------------------- +// Constants + +/** + * Default value for --pane-max-width-diff CSS variable. + * Imported from CSS to ensure JS fallback matches the CSS default. + */ +export const DEFAULT_MAX_WIDTH_DIFF = Number(cssExports.paneMaxWidthDiffDefault) + +// --pane-max-width-diff changes at this breakpoint in PageLayout.module.css. +const DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT = Number(cssExports.paneMaxWidthDiffBreakpoint) +/** + * Default max pane width for SSR when viewport is unknown. + * Updated to actual value in layout effect before paint. + */ +export const SSR_DEFAULT_MAX_WIDTH = 600 + +/** + * Pixel increment for keyboard arrow key resizing. + * @see https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 + */ +export const ARROW_KEY_STEP = 3 + +/** Default widths for preset size options */ +export const defaultPaneWidth: Record = {small: 256, medium: 296, large: 320} + +// ---------------------------------------------------------------------------- +// Helper functions + +export const isCustomWidthOptions = (width: PaneWidth | CustomWidthOptions): width is CustomWidthOptions => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return (width as CustomWidthOptions).default !== undefined +} + +export const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => { + return ['small', 'medium', 'large'].includes(width as PaneWidth) +} + +export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => { + if (isPaneWidth(w)) { + return defaultPaneWidth[w] + } else if (isCustomWidthOptions(w)) { + return parseInt(w.default, 10) + } + return 0 +} + +/** + * Gets the --pane-max-width-diff CSS variable value from a pane element. + * This value is set by CSS media queries and controls the max pane width constraint. + * Note: This calls getComputedStyle which forces layout - cache the result when possible. + */ +export function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number { + if (!paneElement) return DEFAULT_MAX_WIDTH_DIFF + const value = parseInt(getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff'), 10) + return value > 0 ? value : DEFAULT_MAX_WIDTH_DIFF +} + +// Helper to update ARIA slider attributes via direct DOM manipulation +// This avoids re-renders when values change during drag or on viewport resize +export const updateAriaValues = ( + handle: HTMLElement | null, + values: {current?: number; min?: number; max?: number}, +) => { + if (!handle) return + if (values.min !== undefined) handle.setAttribute('aria-valuemin', String(values.min)) + if (values.max !== undefined) handle.setAttribute('aria-valuemax', String(values.max)) + if (values.current !== undefined) { + handle.setAttribute('aria-valuenow', String(values.current)) + handle.setAttribute('aria-valuetext', `Pane width ${values.current} pixels`) + } +} + +// ---------------------------------------------------------------------------- +// Hook + +/** + * Manages pane width state with localStorage persistence and viewport constraints. + * Handles initialization from storage, clamping on viewport resize, and provides + * functions to save and reset width. + */ +export function usePaneWidth({ + width, + minWidth, + resizable, + widthStorageKey, + paneRef, + handleRef, +}: UsePaneWidthOptions): UsePaneWidthResult { + // Derive constraints from width configuration + const isCustomWidth = isCustomWidthOptions(width) + const minPaneWidth = isCustomWidth ? parseInt(width.min, 10) : minWidth + const customMaxWidth = isCustomWidth ? parseInt(width.max, 10) : null + + // Cache the CSS variable value to avoid getComputedStyle during drag (causes layout thrashing) + // Updated on mount and resize when breakpoints might change + const maxWidthDiffRef = React.useRef(DEFAULT_MAX_WIDTH_DIFF) + + // Calculate max width constraint - for custom widths this is fixed, otherwise viewport-dependent + const getMaxPaneWidth = React.useCallback(() => { + if (customMaxWidth !== null) return customMaxWidth + const viewportWidth = window.innerWidth + return viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth + }, [customMaxWidth, minPaneWidth]) + + // --- State --- + // Current width for React renders (ARIA attributes). Updates go through saveWidth() or clamp on resize. + const [currentWidth, setCurrentWidth] = React.useState(() => getDefaultPaneWidth(width)) + // Mutable ref for drag operations - avoids re-renders on every pixel move + const currentWidthRef = React.useRef(currentWidth) + // Max width for ARIA - SSR uses custom max or a sensible default, updated on mount + const [maxPaneWidth, setMaxPaneWidth] = React.useState(() => customMaxWidth ?? SSR_DEFAULT_MAX_WIDTH) + + // --- Callbacks --- + const getDefaultWidth = React.useCallback(() => getDefaultPaneWidth(width), [width]) + + const saveWidth = React.useCallback( + (value: number) => { + currentWidthRef.current = value + setCurrentWidth(value) + try { + localStorage.setItem(widthStorageKey, value.toString()) + } catch { + // Ignore write errors (private browsing, quota exceeded, etc.) + } + }, + [widthStorageKey], + ) + + // --- Effects --- + // Track whether we've initialized the width from localStorage + const initializedRef = React.useRef(false) + + // Initialize from localStorage on mount (before paint to avoid CLS) + useIsomorphicLayoutEffect(() => { + if (initializedRef.current || !resizable) return + initializedRef.current = true + + try { + const stored = localStorage.getItem(widthStorageKey) + if (stored !== null) { + const parsed = Number(stored) + if (!isNaN(parsed) && parsed > 0) { + currentWidthRef.current = parsed + paneRef.current?.style.setProperty('--pane-width', `${parsed}px`) + setCurrentWidth(parsed) + } + } + } catch { + // localStorage unavailable - keep default + } + }, [widthStorageKey, paneRef, resizable]) + + // Stable ref to getMaxPaneWidth for use in resize handler without re-subscribing + const getMaxPaneWidthRef = React.useRef(getMaxPaneWidth) + useIsomorphicLayoutEffect(() => { + getMaxPaneWidthRef.current = getMaxPaneWidth + }) + + // Update max pane width on mount and window resize for accurate ARIA values + useIsomorphicLayoutEffect(() => { + if (!resizable) return + + let lastViewportWidth = window.innerWidth + + const updateMax = ({forceRecalcCss = false}: {forceRecalcCss?: boolean} = {}) => { + // Track last viewport width to detect breakpoint crossings. + const currentViewportWidth = window.innerWidth + const crossedBreakpoint = + (lastViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT && + currentViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT) || + (lastViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT && + currentViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT) + lastViewportWidth = currentViewportWidth + + // Only call getComputedStyle if we crossed the breakpoint (expensive operation) + if (forceRecalcCss || crossedBreakpoint) { + maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) + } + + const actualMax = getMaxPaneWidthRef.current() + setMaxPaneWidth(actualMax) + + // Clamp current width if it exceeds new max (viewport shrunk) + if (currentWidthRef.current > actualMax) { + currentWidthRef.current = actualMax + paneRef.current?.style.setProperty('--pane-width', `${actualMax}px`) + setCurrentWidth(actualMax) + } + + updateAriaValues(handleRef.current, {min: minPaneWidth, max: actualMax, current: currentWidthRef.current}) + } + + // Initial calculation - force CSS recalc to get correct value + updateMax({forceRecalcCss: true}) + + // For custom widths, max is fixed - no need to listen to resize + if (customMaxWidth !== null) return + + // Throttle resize with trailing edge: + // - Execute at most once per THROTTLE_MS during rapid resizing + // - Guarantee final event executes when resize stops + const THROTTLE_MS = 100 + let timeoutId: ReturnType | null = null + let lastExecution = 0 + + const handleResize = () => { + const now = Date.now() + const timeSinceLastExecution = now - lastExecution + + if (timeoutId !== null) { + clearTimeout(timeoutId) + timeoutId = null + } + + if (timeSinceLastExecution >= THROTTLE_MS) { + lastExecution = now + updateMax() + } else { + // Schedule trailing edge execution + timeoutId = setTimeout(() => { + lastExecution = Date.now() + updateMax() + timeoutId = null + }, THROTTLE_MS - timeSinceLastExecution) + } + } + + // eslint-disable-next-line github/prefer-observers -- Uses window resize events instead of ResizeObserver to avoid INP issues. ResizeObserver on document.documentElement fires on any content change (typing, etc), while window resize only fires on actual viewport changes. + window.addEventListener('resize', handleResize) + return () => { + if (timeoutId !== null) clearTimeout(timeoutId) + window.removeEventListener('resize', handleResize) + } + }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef]) + + return { + currentWidth, + currentWidthRef, + minPaneWidth, + maxPaneWidth, + getMaxPaneWidth, + saveWidth, + getDefaultWidth, + } +}