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

Add custom persistence options to PageLayout.Pane's `resizable` prop and number support for `width`

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)
- `{save: fn}` - Enable resizing with custom persistence (e.g., server-side, IndexedDB)

The `width` prop now accepts numbers in addition to named sizes and custom objects:

- `'small' | 'medium' | 'large'` - Preset width names (existing behavior)
- `number` - Explicit pixel width (uses `minWidth` prop and viewport-based max) **NEW**
- `{min, default, max}` - Custom width configuration (existing behavior)

**New types exported:**

- `NoPersistConfig` - Type for `{persist: false}` configuration
- `CustomPersistConfig` - Type for `{save: fn}` configuration
- `SaveOptions` - Options passed to custom save function (`{widthStorageKey: string}`)
- `ResizableConfig` - Union type for all resizable configurations
- `PaneWidth` - Type for preset width names (`'small' | 'medium' | 'large'`)
- `PaneWidthValue` - Union type for all width values (`PaneWidth | number | 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}} />

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

// Number width - uses minWidth prop and viewport-based max constraints
const [savedWidth, setSavedWidth] = useState(defaultPaneWidth.medium)
<PageLayout.Pane
width={savedWidth}
resizable={{
save: (width) => setSavedWidth(width)
}}
/>

// Using defaultPaneWidth for custom width configurations
import {defaultPaneWidth} from '@primer/react'

const widthConfig = {
min: '256px',
default: `${defaultPaneWidth.medium}px`,
max: '600px'
}
<PageLayout.Pane width={widthConfig} resizable={{persist: false}} />
```
120 changes: 119 additions & 1 deletion packages/react/src/PageLayout/PageLayout.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
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 type {CustomWidthOptions} from './usePaneWidth'
import {defaultPaneWidth} from './usePaneWidth'

export default {
title: 'Components/PageLayout/Features',
Expand Down Expand Up @@ -358,3 +361,118 @@ 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 = (): CustomWidthOptions => {
const base: CustomWidthOptions = {min: '256px', default: `${defaultPaneWidth.medium}px`, max: '600px'}
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 base
}

const [widthConfig, setWidthConfig] = React.useState<CustomWidthOptions>(getInitialWidth)
useIsomorphicLayoutEffect(() => {
setWidthConfig(getInitialWidth())
}, [])
return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width={widthConfig}
resizable={{
save: width => {
setWidthConfig(prev => ({...prev, default: `${width}px`}))
localStorage.setItem(key, width.toString())
},
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${widthConfig.default})`} />
</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 [width, setWidth] = React.useState<number>(getInitialWidth)

return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width={width}
resizable={{
save: newWidth => {
setWidth(newWidth)
localStorage.setItem(key, newWidth.toString())
},
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${width}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'
39 changes: 29 additions & 10 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import {
updateAriaValues,
isCustomWidthOptions,
isPaneWidth,
isNumericWidth,
ARROW_KEY_STEP,
type CustomWidthOptions,
type PaneWidth,
type PaneWidthValue,
type ResizableConfig,
} from './usePaneWidth'

const REGION_ORDER = {
Expand Down Expand Up @@ -559,9 +560,22 @@ export type PageLayoutPaneProps = {
positionWhenNarrow?: 'inherit' | keyof typeof panePositions
'aria-labelledby'?: string
'aria-label'?: string
width?: PaneWidth | CustomWidthOptions
/**
* The width of the pane.
* - Named sizes: `'small'` | `'medium'` | `'large'`
* - Number: explicit pixel width (uses `minWidth` prop and viewport-based max)
* - Custom object: `{min: string, default: string, max: string}`
*/
width?: PaneWidthValue
minWidth?: number
resizable?: boolean
/**
* 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)
*/
resizable?: ResizableConfig
widthStorageKey?: string
padding?: keyof typeof SPACING_MAP
divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'>
Expand Down Expand Up @@ -709,10 +723,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 All @@ -723,7 +738,11 @@ 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 : undefined,
'--pane-width-custom': isCustomWidthOptions(width)
? width.default
: isNumericWidth(width)
? `${width}px`
: undefined,
'--pane-width-size': `var(--pane-width-${isPaneWidth(width) ? width : 'custom'})`,
'--pane-width': `${currentWidth}px`,
} as React.CSSProperties
Expand All @@ -746,7 +765,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 {
NoPersistConfig,
CustomPersistConfig,
SaveOptions,
ResizableConfig,
PaneWidth,
PaneWidthValue,
} from './usePaneWidth'
export {defaultPaneWidth} from './usePaneWidth'
Loading
Loading