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
95 changes: 44 additions & 51 deletions .changeset/pagelayout-resizable-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,87 +2,80 @@
'@primer/react': minor
---

Add custom persistence options to PageLayout.Pane's `resizable` prop with controlled width support
Refine `PageLayout.Pane` resizable persistence API based on design review feedback

The `resizable` prop now accepts additional configuration options:
The `resizable` prop now has a refined API with clearer configuration types and better type safety:

- `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
**New Configuration Types:**

**Key Features:**
- `{persist: false}` - Enable resizing without persistence (SSR-safe, width not allowed)
- `{persist: 'localStorage', widthStorageKey: string, width: number | undefined}` - localStorage persistence with required widthStorageKey and width
- `{persist: fn, width: number | undefined}` - Custom persistence with required width

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
**Key Changes:**

**New types exported:**
1. **`width` required for persistence configs**: When using localStorage or custom persistence, `width` is now required (can be `undefined` to use default). This ensures intentional state management.

- `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`
2. **`widthStorageKey` in config for localStorage**: The storage key is now part of the localStorage config object, making it explicit and avoiding accidental collisions.

**New values exported:**
3. **Custom persist functions simplified**: Custom persist functions no longer receive `widthStorageKey` - consumers manage their own storage keys and mechanisms.

- `defaultPaneWidth` - Record of preset width values: `{small: 256, medium: 296, large: 320}`
4. **Backwards compatibility maintained**: `resizable={true}` with `widthStorageKey` prop still works but is deprecated.

**Example usage:**
**Deprecations:**

- `widthStorageKey` prop is deprecated - use `resizable={{persist: 'localStorage', widthStorageKey: '...', width}}` instead
- `resizable={true}` is implicitly deprecated - use the explicit config forms instead

**New Exported Types:**

- `NoPersistConfig` - Type for `{persist: false}`
- `LocalStoragePersistConfig` - Type for localStorage configuration
- `CustomPersistConfig` - Type for custom persistence configuration

**Migration Examples:**

```tsx
// No persistence - useful for SSR to avoid hydration mismatches
<PageLayout.Pane resizable={{persist: false}} />
// Before: Default localStorage (deprecated)
<PageLayout.Pane resizable={true} widthStorageKey="my-pane" />

// Explicit localStorage persistence
<PageLayout.Pane resizable={{persist: 'localStorage'}} />
// After: Explicit localStorage with widthStorageKey in config
<PageLayout.Pane resizable={{persist: 'localStorage', widthStorageKey: 'my-pane', width: undefined}} />

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

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

// Using named size for constraints with controlled current width
// SSR-safe resizing without persistence (no change needed)
<PageLayout.Pane resizable={{persist: false}} />

// Controlled width with custom persistence
const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.medium)
<PageLayout.Pane
width="medium"
resizable={{
width: currentWidth,
persist: (width) => setCurrentWidth(width)
persist: (width) => setCurrentWidth(width),
width: currentWidth
}}
/>
```

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

const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.large)
<PageLayout.Pane
width="large"
resizable={{
width: currentWidth,
persist: false
}}
/>
```
1. **Explicit is better**: Requiring `width` for persistence configs makes state management intentional
2. **Clearer storage key management**: localStorage configs own their storage key; custom persisters manage their own
3. **Better SSR support**: `{persist: false}` makes it clear there's no persistence to worry about
4. **Type safety**: Separate config types provide better IntelliSense and type checking
115 changes: 115 additions & 0 deletions packages/react/src/PageLayout/PageLayout.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,118 @@ export const ResizablePaneWithControlledWidth: StoryFn = () => {
)
}
ResizablePaneWithControlledWidth.storyName = 'Resizable pane with controlled width (new API)'

/**
* Migration Examples: Old API → New API
*
* This story demonstrates migration patterns from the deprecated API to the new refined API.
*/
export const ResizablePaneMigrationExamples: StoryFn = () => {
const [currentWidth, setCurrentWidth] = React.useState<number>(defaultPaneWidth.medium)

const codeStyle = {
background: '#f6f8fa',
padding: '1rem',
borderRadius: '6px',
fontSize: '12px',
fontFamily: 'monospace',
whiteSpace: 'pre' as const,
display: 'block',
}

// Code examples stored as variables to avoid HTML literal warnings
const oldApiExample1 = 'resizable={true}\nwidthStorageKey="my-pane"'
const newApiExample1 =
"resizable={{\n persist: 'localStorage',\n widthStorageKey: 'my-pane',\n width: undefined\n}}"
const newApiExample2 = 'resizable={{persist: false}}'
const oldApiExample3 =
'resizable={{\n persist: (width, {widthStorageKey}) => {\n myStorage.set(widthStorageKey, width)\n }\n}}\nwidthStorageKey="my-key"'
const newApiExample3 =
"resizable={{\n persist: (width) => {\n myStorage.set('my-key', width)\n },\n width: currentWidth\n}}"

return (
<div style={{display: 'flex', flexDirection: 'column', gap: '2rem'}}>
<div>
<Text as="h3" sx={{fontSize: 3, fontWeight: 'bold'}}>
Example 1: Default localStorage (Deprecated → New)
</Text>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem'}}>
<div>
<Text as="h4" sx={{fontSize: 2, fontWeight: 'bold'}}>
Old API (Deprecated):
</Text>
<code style={codeStyle}>{oldApiExample1}</code>
</div>
<div>
<Text as="h4" sx={{fontSize: 2, fontWeight: 'bold'}}>
New API:
</Text>
<code style={codeStyle}>{newApiExample1}</code>
</div>
</div>
</div>

<div>
<Text as="h3" sx={{fontSize: 3, fontWeight: 'bold'}}>
Example 2: No Persistence (SSR-safe)
</Text>
<div style={{display: 'grid', gridTemplateColumns: '1fr', gap: '1rem', marginTop: '1rem'}}>
<div>
<Text as="h4" sx={{fontSize: 2, fontWeight: 'bold'}}>
New API (no old equivalent):
</Text>
<code style={codeStyle}>{newApiExample2}</code>
<Text as="p" sx={{fontSize: 1, mt: 2}}>
Use this for SSR apps to avoid hydration mismatches
</Text>
</div>
</div>
</div>

<div>
<Text as="h3" sx={{fontSize: 3, fontWeight: 'bold'}}>
Example 3: Custom Persistence (Simplified)
</Text>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem'}}>
<div>
<Text as="h4" sx={{fontSize: 2, fontWeight: 'bold'}}>
Old API:
</Text>
<code style={codeStyle}>{oldApiExample3}</code>
</div>
<div>
<Text as="h4" sx={{fontSize: 2, fontWeight: 'bold'}}>
New API (Simplified):
</Text>
<code style={codeStyle}>{newApiExample3}</code>
</div>
</div>
</div>

<div>
<Text as="h3" sx={{fontSize: 3, fontWeight: 'bold'}}>
Live Example: Controlled Width with Custom Persistence
</Text>
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width="medium"
resizable={{
persist: width => setCurrentWidth(width),
width: currentWidth,
}}
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${currentWidth}px)`} />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
</PageLayout>
</div>
</div>
)
}
ResizablePaneMigrationExamples.storyName = 'Migration Examples (Old → New API)'
15 changes: 8 additions & 7 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -606,16 +606,17 @@ export type PageLayoutPaneProps = {
minWidth?: number
/**
* Enable resizable pane behavior.
* - `true`: Enable with default localStorage persistence
* - `true`: Enable with default localStorage persistence (DEPRECATED - use `{persist: 'localStorage', widthStorageKey, width}`)
* - `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.
* - `{persist: false}`: Enable without persistence (SSR-safe)
* - `{persist: 'localStorage', widthStorageKey, width}`: Enable with localStorage, width required
* - `{persist: fn, width}`: Enable with custom persistence, width required
*/
resizable?: ResizableConfig
/**
* @deprecated Use `resizable={{persist: 'localStorage', widthStorageKey: '...', width: undefined}}` instead.
* Only used for backwards compatibility when `resizable={true}`.
*/
widthStorageKey?: string
padding?: keyof typeof SPACING_MAP
divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'>
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/PageLayout/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export * from './PageLayout'
export type {
NoPersistConfig,
LocalStoragePersistConfig,
CustomPersistConfig,
PersistConfig,
PersistFunction,
SaveOptions,
ResizableConfig,
PaneWidth,
PaneWidthValue,
Expand Down
Loading
Loading