Skip to content
Draft
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
88 changes: 88 additions & 0 deletions .changeset/pagelayout-resizable-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
'@primer/react': minor
---

Add custom persistence options to PageLayout.Pane's `resizable` prop with controlled width support

The `resizable` prop now accepts additional configuration options:

- `true` - Enable resizing with default localStorage persistence (existing behavior)
- `false` - Disable resizing (existing behavior)
- `{persist: false}` - Enable resizing without any persistence (avoids hydration mismatches)
- `{persist: 'localStorage'}` - Enable resizing with explicit localStorage persistence
- `{persist: fn}` - Enable resizing with custom persistence function (e.g., server-side, IndexedDB)
- `{width: number, persist: ...}` - Controlled width mode: provide current width and persistence handler

**Key Features:**

1. **Flexible persistence**: Choose between no persistence, localStorage, or custom persistence function
2. **Controlled width support**: Separate current width from default constraints using `resizable.width`
3. **SSR-friendly**: No persistence mode avoids hydration mismatches in server-rendered apps

**New types exported:**

- `PersistFunction` - Type for custom persistence function: `(width: number, options: SaveOptions) => void | Promise<void>`
- `SaveOptions` - Options passed to custom persist function: `{widthStorageKey: string}`
- `PersistConfig` - Configuration object: `{width?: number, persist: false | 'localStorage' | PersistFunction}`
- `ResizableConfig` - Union type for all resizable configurations: `boolean | PersistConfig`
- `PaneWidth` - Type for preset width names: `'small' | 'medium' | 'large'`
- `PaneWidthValue` - Union type for width prop: `PaneWidth | CustomWidthOptions`

**New values exported:**

- `defaultPaneWidth` - Record of preset width values: `{small: 256, medium: 296, large: 320}`

**Example usage:**

```tsx
// No persistence - useful for SSR to avoid hydration mismatches
<PageLayout.Pane resizable={{persist: false}} />

// Explicit localStorage persistence
<PageLayout.Pane resizable={{persist: 'localStorage'}} />

// Custom persistence function - save to your own storage
<PageLayout.Pane
resizable={{
persist: (width, {widthStorageKey}) => {
// Save to server, IndexedDB, sessionStorage, etc.
myStorage.set(widthStorageKey, width)
}
}}
/>

// Controlled width - separate current value from constraints
const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.medium)
<PageLayout.Pane
width={{min: '256px', default: '296px', max: '600px'}}
resizable={{
width: currentWidth,
persist: (width) => {
setCurrentWidth(width)
localStorage.setItem('my-pane-width', width.toString())
}
}}
/>

// Using named size for constraints with controlled current width
const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.medium)
<PageLayout.Pane
width="medium"
resizable={{
width: currentWidth,
persist: (width) => setCurrentWidth(width)
}}
/>

// Using defaultPaneWidth for initialization
import {defaultPaneWidth} from '@primer/react'

const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.large)
<PageLayout.Pane
width="large"
resizable={{
width: currentWidth,
persist: false
}}
/>
```
168 changes: 167 additions & 1 deletion packages/react/src/PageLayout/PageLayout.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type {Meta, StoryFn} from '@storybook/react-vite'
import React from 'react'
import {PageLayout} from './PageLayout'
import {Placeholder} from '../Placeholder'
import {BranchName, Heading, Link, StateLabel, Text} from '..'
import {BranchName, Heading, Link, StateLabel, Text, useIsomorphicLayoutEffect} from '..'
import TabNav from '../TabNav'
import classes from './PageLayout.features.stories.module.css'
import {defaultPaneWidth} from './usePaneWidth'

export default {
title: 'Components/PageLayout/Features',
Expand Down Expand Up @@ -358,3 +360,167 @@ export const WithCustomPaneHeading: StoryFn = () => (
</PageLayout.Footer>
</PageLayout>
)

export const ResizablePaneWithoutPersistence: StoryFn = () => (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane resizable={{persist: false}} aria-label="Side pane">
<Placeholder height={320} label="Pane (resizable, not persisted)" />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
<PageLayout.Footer>
<Placeholder height={64} label="Footer" />
</PageLayout.Footer>
</PageLayout>
)
ResizablePaneWithoutPersistence.storyName = 'Resizable pane without persistence'

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 = (): number => {
if (typeof window !== 'undefined') {
const storedWidth = localStorage.getItem(key)
if (storedWidth !== null) {
const parsed = parseFloat(storedWidth)
if (!isNaN(parsed) && parsed > 0) {
return parsed
}
}
}
return defaultPaneWidth.medium
}

const [currentWidth, setCurrentWidth] = React.useState<number>(getInitialWidth)
useIsomorphicLayoutEffect(() => {
setCurrentWidth(getInitialWidth())
}, [])
return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width={{min: '256px', default: `${defaultPaneWidth.medium}px`, max: '600px'}}
resizable={{
width: currentWidth,
persist: width => {
setCurrentWidth(width)
localStorage.setItem(key, width.toString())
},
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${currentWidth}px)`} />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
<PageLayout.Footer>
<Placeholder height={64} label="Footer" />
</PageLayout.Footer>
</PageLayout>
)
}
ResizablePaneWithCustomPersistence.storyName = 'Resizable pane with custom persistence'

export const ResizablePaneWithNumberWidth: StoryFn = () => {
const key = 'page-layout-features-stories-number-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="medium"
resizable={{
width: currentWidth,
persist: newWidth => {
setCurrentWidth(newWidth)
localStorage.setItem(key, newWidth.toString())
},
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${currentWidth}px)`} />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
<PageLayout.Footer>
<Placeholder height={64} label="Footer" />
</PageLayout.Footer>
</PageLayout>
)
}
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)'
37 changes: 28 additions & 9 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import {
isCustomWidthOptions,
isPaneWidth,
ARROW_KEY_STEP,
type CustomWidthOptions,
type PaneWidth,
type PaneWidthValue,
type ResizableConfig,
} from './usePaneWidth'
import {setDraggingStyles, removeDraggingStyles} from './paneUtils'

Expand Down Expand Up @@ -595,9 +595,27 @@ export type PageLayoutPaneProps = {
positionWhenNarrow?: 'inherit' | keyof typeof panePositions
'aria-labelledby'?: string
'aria-label'?: string
width?: PaneWidth | CustomWidthOptions
/**
* The width of the pane - defines constraints and defaults only.
* - Named sizes: `'small'` | `'medium'` | `'large'`
* - Custom object: `{min: string, default: string, max: string}`
*
* For controlled width (current value), use `resizable.width` instead.
*/
width?: PaneWidthValue
minWidth?: number
resizable?: boolean
/**
* Enable resizable pane behavior.
* - `true`: Enable with default localStorage persistence
* - `false`: Disable resizing
* - `{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
padding?: keyof typeof SPACING_MAP
divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'>
Expand Down Expand Up @@ -746,10 +764,11 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
/>
<div
ref={paneRef}
// suppressHydrationWarning: We intentionally read from localStorage during
// useState init to avoid resize flicker, which causes a hydration mismatch
// for --pane-width. This only affects this element, not children.
suppressHydrationWarning
// suppressHydrationWarning: Only needed when resizable===true (default localStorage
// persister). We read from localStorage during useState init to avoid resize flicker,
// which causes a hydration mismatch for --pane-width. Custom persisters ({save} object)
// and empty object ({}) don't read localStorage, so no suppression needed.
suppressHydrationWarning={resizable === true}
{...(hasOverflow ? overflowProps : {})}
{...labelProp}
{...(id && {id: paneId})}
Expand Down Expand Up @@ -783,7 +802,7 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
}
}
// If pane is resizable, the divider should be draggable
draggable={resizable}
draggable={!!resizable}
position={positionProp}
className={classes.PaneVerticalDivider}
style={
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/PageLayout/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
export * from './PageLayout'
export type {
PersistConfig,
PersistFunction,
SaveOptions,
ResizableConfig,
PaneWidth,
PaneWidthValue,
} from './usePaneWidth'
export {defaultPaneWidth} from './usePaneWidth'
Loading
Loading