diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index 9bb0d4e28e0..fc08b95b767 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -5,7 +5,6 @@ import {Placeholder} from '../Placeholder' import {BranchName, Heading, Link, StateLabel, Text, useIsomorphicLayoutEffect} from '..' import TabNav from '../TabNav' import classes from './PageLayout.features.stories.module.css' -import type {CustomWidthOptions} from './usePaneWidth' import {defaultPaneWidth} from './usePaneWidth' export default { @@ -384,23 +383,22 @@ export const ResizablePaneWithCustomPersistence: StoryFn = () => { const key = 'page-layout-features-stories-custom-persistence-pane-width' // Read initial width from localStorage (CSR only), falling back to medium preset - const getInitialWidth = (): CustomWidthOptions => { - const base: CustomWidthOptions = {min: '256px', default: `${defaultPaneWidth.medium}px`, max: '600px'} + const getInitialWidth = (): number => { if (typeof window !== 'undefined') { const storedWidth = localStorage.getItem(key) if (storedWidth !== null) { const parsed = parseFloat(storedWidth) if (!isNaN(parsed) && parsed > 0) { - return {...base, default: `${parsed}px`} + return parsed } } } - return base + return defaultPaneWidth.medium } - const [widthConfig, setWidthConfig] = React.useState(getInitialWidth) + const [currentWidth, setCurrentWidth] = React.useState(getInitialWidth) useIsomorphicLayoutEffect(() => { - setWidthConfig(getInitialWidth()) + setCurrentWidth(getInitialWidth()) }, []) return ( @@ -408,16 +406,17 @@ export const ResizablePaneWithCustomPersistence: StoryFn = () => { { - setWidthConfig(prev => ({...prev, default: `${width}px`})) + setCurrentWidth(width) localStorage.setItem(key, width.toString()) }, }} aria-label="Side pane" > - + @@ -447,7 +446,7 @@ export const ResizablePaneWithNumberWidth: StoryFn = () => { return defaultPaneWidth.medium } - const [width, setWidth] = React.useState(getInitialWidth) + const [currentWidth, setCurrentWidth] = React.useState(getInitialWidth) return ( @@ -455,16 +454,17 @@ export const ResizablePaneWithNumberWidth: StoryFn = () => { { - setWidth(newWidth) + setCurrentWidth(newWidth) localStorage.setItem(key, newWidth.toString()) }, }} aria-label="Side pane" > - + @@ -476,3 +476,51 @@ export const ResizablePaneWithNumberWidth: StoryFn = () => { ) } ResizablePaneWithNumberWidth.storyName = 'Resizable pane with number width' + +export const ResizablePaneWithControlledWidth: StoryFn = () => { + const key = 'page-layout-features-stories-controlled-width' + + // Read initial width from localStorage (CSR only), falling back to medium preset + const getInitialWidth = (): number => { + if (typeof window !== 'undefined') { + const storedWidth = localStorage.getItem(key) + if (storedWidth !== null) { + const parsed = parseInt(storedWidth, 10) + if (!isNaN(parsed) && parsed > 0) { + return parsed + } + } + } + return defaultPaneWidth.medium + } + + const [currentWidth, setCurrentWidth] = React.useState(getInitialWidth) + + return ( + + + + + { + setCurrentWidth(newWidth) + localStorage.setItem(key, newWidth.toString()) + }, + }} + aria-label="Side pane" + > + + + + + + + + + + ) +} +ResizablePaneWithControlledWidth.storyName = 'Resizable pane with controlled width (new API)' diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 4f6a9067424..2b41576f61e 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -16,7 +16,6 @@ import { updateAriaValues, isCustomWidthOptions, isPaneWidth, - isNumericWidth, ARROW_KEY_STEP, type PaneWidthValue, type ResizableConfig, @@ -597,10 +596,11 @@ export type PageLayoutPaneProps = { 'aria-labelledby'?: string 'aria-label'?: string /** - * The width of the pane. + * The width of the pane - defines constraints and defaults only. * - Named sizes: `'small'` | `'medium'` | `'large'` - * - Number: explicit pixel width (uses `minWidth` prop and viewport-based max) * - Custom object: `{min: string, default: string, max: string}` + * + * For controlled width (current value), use `resizable.width` instead. */ width?: PaneWidthValue minWidth?: number @@ -608,8 +608,12 @@ export type PageLayoutPaneProps = { * Enable resizable pane behavior. * - `true`: Enable with default localStorage persistence * - `false`: Disable resizing - * - `{persist: false}`: Enable without persistence (no hydration issues) - * - `{save: fn}`: Enable with custom persistence (e.g., server-side, IndexedDB) + * - `{width?: number, persist: false}`: Enable without persistence, optionally with controlled current width + * - `{width?: number, persist: 'localStorage'}`: Enable with localStorage, optionally with controlled current width + * - `{width?: number, persist: fn}`: Enable with custom persistence, optionally with controlled current width + * + * The `width` property in the config represents the current/controlled width value. + * When provided, it takes precedence over the default width from the `width` prop. */ resizable?: ResizableConfig widthStorageKey?: string @@ -775,11 +779,7 @@ const Pane = React.forwardRef { // But localStorage should not be written expect(localStorage.getItem('test-pane')).toBeNull() }) + + it('should initialize with resizable.width when provided', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: {width: 400, persist: false}, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + // Should use resizable.width, not the default from width prop + expect(result.current.currentWidth).toBe(400) + }) + + it('should prefer resizable.width over localStorage', () => { + localStorage.setItem('test-pane', '350') + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: {width: 500, persist: 'localStorage'}, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + // Should use resizable.width, not localStorage + expect(result.current.currentWidth).toBe(500) + }) + + it('should sync when resizable.width changes', () => { + const refs = createMockRefs() + type ResizableType = {width?: number; persist: false} + + const {result, rerender} = renderHook( + ({resizable}: {resizable: ResizableType}) => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable, + widthStorageKey: 'test-sync-resizable', + ...refs, + }), + {initialProps: {resizable: {width: 350, persist: false} as ResizableType}}, + ) + + expect(result.current.currentWidth).toBe(350) + + // Change resizable.width + rerender({resizable: {width: 450, persist: false}}) + + expect(result.current.currentWidth).toBe(450) + }) + + it('should fall back to default when resizable.width is removed', () => { + const refs = createMockRefs() + type ResizableType = {width?: number; persist: false} + + const {result, rerender} = renderHook( + ({resizable}: {resizable: ResizableType}) => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable, + widthStorageKey: 'test-fallback', + ...refs, + }), + {initialProps: {resizable: {width: 400, persist: false} as ResizableType}}, + ) + + expect(result.current.currentWidth).toBe(400) + + // Remove width from resizable config + rerender({resizable: {persist: false}}) + + // Should fall back to default from width prop + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + + it('should not sync width prop default when resizable.width is provided', () => { + const refs = createMockRefs() + type WidthType = 'small' | 'medium' | 'large' + type ResizableType = {width: number; persist: false} + + const {result, rerender} = renderHook( + ({width, resizable}: {width: WidthType; resizable: ResizableType}) => + usePaneWidth({ + width, + minWidth: 256, + resizable, + widthStorageKey: 'test-no-sync', + ...refs, + }), + { + initialProps: { + width: 'medium' as WidthType, + resizable: {width: 400, persist: false} as ResizableType, + }, + }, + ) + + expect(result.current.currentWidth).toBe(400) + + // Change width prop (default changes from 296 to 320) + rerender({width: 'large', resizable: {width: 400, persist: false}}) + + // Should NOT sync to new default because resizable.width is controlling + expect(result.current.currentWidth).toBe(400) + }) }) describe('saveWidth', () => { diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 80038699a49..392ec91e9de 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -16,12 +16,11 @@ export type CustomWidthOptions = { export type PaneWidth = 'small' | 'medium' | 'large' /** - * Width value for the pane. + * Width value for the pane - defines constraints and defaults only. * - `PaneWidth`: Preset size ('small' | 'medium' | 'large') - * - `number`: Custom width in pixels (uses minWidth prop and viewport-based max) * - `CustomWidthOptions`: Explicit min/default/max constraints */ -export type PaneWidthValue = PaneWidth | number | CustomWidthOptions +export type PaneWidthValue = PaneWidth | CustomWidthOptions /** * Options passed to custom persist function. @@ -35,11 +34,14 @@ export type PersistFunction = (width: number, options: SaveOptions) => void | Pr /** * Configuration object for resizable pane. + * - `width?: number` - Current/controlled width value in pixels (overrides width prop's default) * - `persist: false` - Enable resizing without any persistence * - `persist: 'localStorage'` - Enable resizing with localStorage persistence * - `persist: fn` - Enable resizing with custom persistence function */ export type PersistConfig = { + /** Current controlled width value in pixels. When provided, this overrides the default from the width prop. */ + width?: number persist: false | 'localStorage' | PersistFunction } @@ -56,9 +58,12 @@ export const isCustomPersistFunction = ( * Resizable configuration options. * - `true`: Enable resizing with default localStorage persistence (may cause hydration mismatch) * - `false`: Disable resizing - * - `{persist: false}`: Enable resizing without any persistence - * - `{persist: 'localStorage'}`: Enable resizing with localStorage persistence - * - `{persist: fn}`: Enable resizing with custom persistence function + * - `{width?: number, persist: false}`: Enable resizing without any persistence, optionally with controlled width + * - `{width?: number, persist: 'localStorage'}`: Enable resizing with localStorage persistence, optionally with controlled width + * - `{width?: number, persist: fn}`: Enable resizing with custom persistence function, optionally with controlled width + * + * The `width` property in the config object represents the current/controlled width value. + * When provided, it takes precedence over the default width from the `width` prop. */ export type ResizableConfig = boolean | PersistConfig @@ -126,15 +131,9 @@ export const isPaneWidth = (width: PaneWidthValue): width is PaneWidth => { return width === 'small' || width === 'medium' || width === 'large' } -export const isNumericWidth = (width: PaneWidthValue): width is number => { - return typeof width === 'number' -} - export const getDefaultPaneWidth = (w: PaneWidthValue): number => { if (isPaneWidth(w)) { return defaultPaneWidth[w] - } else if (isNumericWidth(w)) { - return w } else if (isCustomWidthOptions(w)) { return parseInt(w.default, 10) } @@ -256,11 +255,15 @@ export function usePaneWidth({ const defaultWidth = useMemo(() => getDefaultPaneWidth(width), [width]) // --- State --- // Current width for React renders (ARIA attributes). Updates go through saveWidth() or clamp on resize. - // For resizable=true (localStorage), we read synchronously in initializer to avoid flash on CSR. - // This causes hydration mismatch (server renders default, client reads localStorage) which is - // suppressed via suppressHydrationWarning on the pane element. - // For other resizable configs ({persist: false}), consumer provides initial via `width` prop. + // Priority order for initial width: + // 1. resizable.width (controlled current value) + // 2. localStorage (resizable === true only) + // 3. defaultWidth (from width prop) const [currentWidth, setCurrentWidth] = React.useState(() => { + // Check if resizable config has a controlled width value + if (isPersistConfig(resizable) && typeof resizable.width === 'number') { + return resizable.width + } // Only try localStorage for default persister (resizable === true) // Read directly here instead of via persister to satisfy react-hooks/refs lint rule if (resizable === true) { @@ -272,12 +275,33 @@ export function usePaneWidth({ return defaultWidth }) - // Inline state sync when width prop changes (avoids effect) + // Inline state sync when width prop or resizable.width changes (avoids effect) // See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes const [prevDefaultWidth, setPrevDefaultWidth] = React.useState(defaultWidth) - if (defaultWidth !== prevDefaultWidth) { + const controlledWidth = isPersistConfig(resizable) ? resizable.width : undefined + const [prevControlledWidth, setPrevControlledWidth] = React.useState(controlledWidth) + + // Handle controlled width changes + const controlledWidthChanged = controlledWidth !== prevControlledWidth + const defaultWidthChanged = defaultWidth !== prevDefaultWidth + + if (controlledWidthChanged) { + setPrevControlledWidth(controlledWidth) + if (typeof controlledWidth === 'number') { + // New controlled width provided + setCurrentWidth(controlledWidth) + } else if (prevControlledWidth !== undefined) { + // Controlled width was removed, fall back to default + setCurrentWidth(defaultWidth) + } + } + + if (defaultWidthChanged) { setPrevDefaultWidth(defaultWidth) - setCurrentWidth(defaultWidth) + // Only sync defaultWidth to currentWidth if there's no controlled width + if (controlledWidth === undefined && !controlledWidthChanged) { + setCurrentWidth(defaultWidth) + } } // Mutable ref for drag operations - avoids re-renders on every pixel move