Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 62 additions & 14 deletions packages/react/src/PageLayout/PageLayout.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -384,40 +383,40 @@ 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<CustomWidthOptions>(getInitialWidth)
const [currentWidth, setCurrentWidth] = React.useState<number>(getInitialWidth)
useIsomorphicLayoutEffect(() => {
setWidthConfig(getInitialWidth())
setCurrentWidth(getInitialWidth())
}, [])
return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width={widthConfig}
width={{min: '256px', default: `${defaultPaneWidth.medium}px`, max: '600px'}}
resizable={{
width: currentWidth,
persist: width => {
setWidthConfig(prev => ({...prev, default: `${width}px`}))
setCurrentWidth(width)
localStorage.setItem(key, width.toString())
},
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${widthConfig.default})`} />
<Placeholder height={320} label={`Pane (width: ${currentWidth}px)`} />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
Expand Down Expand Up @@ -447,24 +446,25 @@ export const ResizablePaneWithNumberWidth: StoryFn = () => {
return defaultPaneWidth.medium
}

const [width, setWidth] = React.useState<number>(getInitialWidth)
const [currentWidth, setCurrentWidth] = React.useState<number>(getInitialWidth)

return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width={width}
width="medium"
resizable={{
width: currentWidth,
persist: newWidth => {
setWidth(newWidth)
setCurrentWidth(newWidth)
localStorage.setItem(key, newWidth.toString())
},
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${width}px)`} />
<Placeholder height={320} label={`Pane (width: ${currentWidth}px)`} />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
Expand All @@ -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<number>(getInitialWidth)

return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width={{min: '256px', default: '296px', max: '600px'}}
resizable={{
width: currentWidth,
persist: newWidth => {
setCurrentWidth(newWidth)
localStorage.setItem(key, newWidth.toString())
},
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (current: ${currentWidth}px)`} />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
<PageLayout.Footer>
<Placeholder height={64} label="Footer" />
</PageLayout.Footer>
</PageLayout>
)
}
ResizablePaneWithControlledWidth.storyName = 'Resizable pane with controlled width (new API)'
20 changes: 10 additions & 10 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
updateAriaValues,
isCustomWidthOptions,
isPaneWidth,
isNumericWidth,
ARROW_KEY_STEP,
type PaneWidthValue,
type ResizableConfig,
Expand Down Expand Up @@ -597,19 +596,24 @@ 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
/**
* 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
Expand Down Expand Up @@ -775,11 +779,7 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
'--spacing': `var(--spacing-${padding})`,
'--pane-min-width': isCustomWidthOptions(width) ? width.min : `${minWidth}px`,
'--pane-max-width': isCustomWidthOptions(width) ? width.max : `calc(100vw - var(--pane-max-width-diff))`,
'--pane-width-custom': isCustomWidthOptions(width)
? width.default
: isNumericWidth(width)
? `${width}px`
: undefined,
'--pane-width-custom': isCustomWidthOptions(width) ? width.default : undefined,
'--pane-width-size': `var(--pane-width-${isPaneWidth(width) ? width : 'custom'})`,
'--pane-width': `${currentWidth}px`,
} as React.CSSProperties
Expand Down
114 changes: 114 additions & 0 deletions packages/react/src/PageLayout/usePaneWidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,120 @@ describe('usePaneWidth', () => {
// 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', () => {
Expand Down
Loading
Loading