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
146 changes: 146 additions & 0 deletions .changeset/clever-grapes-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
'@primer/react': major
---

**BREAKING CHANGES**: Streamline PageLayout.Pane resizable API

This is a major refactoring of the `PageLayout.Pane` resizable API to follow standard React patterns and eliminate hydration issues.

### Breaking Changes

#### Props Renamed/Changed

| Old Prop | New Prop | Description |
|----------|----------|-------------|
| `width` (named size or CustomWidthOptions) | `defaultWidth` (number or named size) | Default width of the pane |
| N/A | `width` (number) | Controlled current width |
| N/A | `onWidthChange` (callback) | Called when width changes |
| N/A | `maxWidth` (number) | Maximum allowed width |
| `resizable` (boolean or PersistConfig) | `resizable` (boolean) | Enable/disable resizing |
| `widthStorageKey` | Removed | Use `useLocalStoragePaneWidth` hook instead |

#### API Changes

**Before:**
```tsx
// With localStorage persistence
<PageLayout.Pane width="medium" resizable widthStorageKey="my-pane" />

// With custom constraints
<PageLayout.Pane
width={{min: '256px', default: '296px', max: '600px'}}
resizable
/>

// Without persistence
<PageLayout.Pane resizable={{persist: false}} />

// With custom persistence
<PageLayout.Pane
resizable={{
width: currentWidth,
persist: (width) => { /* custom save */ }
}}
/>
```

**After:**
```tsx
// Simple resizable (no persistence)
<PageLayout.Pane resizable defaultWidth="medium" />

// With localStorage persistence (using hook)
const [width, setWidth] = useLocalStoragePaneWidth('my-pane', {
defaultWidth: defaultPaneWidth.medium
})
<PageLayout.Pane
resizable
width={width}
onWidthChange={setWidth}
/>

// With custom constraints
<PageLayout.Pane
resizable
defaultWidth={296}
minWidth={256}
maxWidth={600}
/>

// With custom persistence (controlled)
const [width, setWidth] = useState(defaultPaneWidth.medium)
<PageLayout.Pane
resizable
width={width}
onWidthChange={(w) => {
setWidth(w)
// Custom persistence logic
}}
/>
```

### New Exports

- **`useLocalStoragePaneWidth(key, options)`** - Hook for localStorage persistence (SSR-safe)
- **`defaultPaneWidth`** - Object with preset width values: `{small: 256, medium: 296, large: 320}`

### Migration Guide

1. **Simple resizable pane** - No changes needed if not using persistence:
```tsx
// Before & After
<PageLayout.Pane resizable />
```

2. **With localStorage** - Use the new hook:
```tsx
// Before
<PageLayout.Pane resizable widthStorageKey="my-pane" />

// After
const [width, setWidth] = useLocalStoragePaneWidth('my-pane', {
defaultWidth: defaultPaneWidth.medium
})
<PageLayout.Pane resizable width={width} onWidthChange={setWidth} />
```

3. **With custom constraints** - Use separate props:
```tsx
// Before
<PageLayout.Pane
width={{min: '256px', default: '296px', max: '600px'}}
/>

// After
<PageLayout.Pane
defaultWidth={296}
minWidth={256}
maxWidth={600}
/>
```

4. **With custom persistence** - Use controlled pattern:
```tsx
// Before
<PageLayout.Pane
resizable={{
width: currentWidth,
persist: (w) => setCurrentWidth(w)
}}
/>

// After
<PageLayout.Pane
resizable
width={currentWidth}
onWidthChange={setCurrentWidth}
/>
```

### Benefits

- **Standard React patterns** - Follows controlled/uncontrolled component conventions
- **SSR-safe by default** - No hydration mismatches
- **Simpler API** - Separate concerns into separate props
- **Better TypeScript support** - No complex union types
- **More flexible** - Easy to compose with other state management
137 changes: 57 additions & 80 deletions packages/react/src/PageLayout/PageLayout.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type {Meta, StoryFn} from '@storybook/react-vite'
import React from 'react'
import {PageLayout} from './PageLayout'
import {useLocalStoragePaneWidth} from './useLocalStoragePaneWidth'
import {Placeholder} from '../Placeholder'
import {BranchName, Heading, Link, StateLabel, Text, useIsomorphicLayoutEffect} from '..'
import {BranchName, Heading, Link, StateLabel, Text} from '..'
import TabNav from '../TabNav'
import classes from './PageLayout.features.stories.module.css'
import {defaultPaneWidth} from './usePaneWidth'
Expand Down Expand Up @@ -329,7 +330,7 @@ export const CustomPaneWidths: StoryFn = () => (
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane resizable width={{min: '200px', default: '300px', max: '400px'}} aria-label="Side pane">
<PageLayout.Pane resizable defaultWidth={300} minWidth={200} maxWidth={400} aria-label="Side pane">
<Placeholder height={320} label="Pane" />
</PageLayout.Pane>
<PageLayout.Content>
Expand Down Expand Up @@ -366,7 +367,7 @@ export const ResizablePaneWithoutPersistence: StoryFn = () => (
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane resizable={{persist: false}} aria-label="Side pane">
<PageLayout.Pane resizable aria-label="Side pane">
<Placeholder height={320} label="Pane (resizable, not persisted)" />
</PageLayout.Pane>
<PageLayout.Content>
Expand All @@ -380,40 +381,20 @@ export const ResizablePaneWithoutPersistence: StoryFn = () => (
ResizablePaneWithoutPersistence.storyName = 'Resizable pane without persistence'

export const ResizablePaneWithCustomPersistence: StoryFn = () => {
const key = 'page-layout-features-stories-custom-persistence-pane-width'
const [currentWidth, setCurrentWidth] = React.useState<number>(defaultPaneWidth.medium)

// 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())
},
}}
defaultWidth={defaultPaneWidth.medium}
minWidth={256}
maxWidth={600}
width={currentWidth}
onWidthChange={setCurrentWidth}
resizable
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${currentWidth}px)`} />
Expand All @@ -430,38 +411,18 @@ export const ResizablePaneWithCustomPersistence: StoryFn = () => {
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)
const [currentWidth, setCurrentWidth] = React.useState<number>(defaultPaneWidth.medium)

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())
},
}}
defaultWidth="medium"
width={currentWidth}
onWidthChange={setCurrentWidth}
resizable
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (width: ${currentWidth}px)`} />
Expand All @@ -478,38 +439,20 @@ 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)
const [currentWidth, setCurrentWidth] = React.useState<number>(defaultPaneWidth.medium)

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())
},
}}
defaultWidth={296}
minWidth={256}
maxWidth={600}
width={currentWidth}
onWidthChange={setCurrentWidth}
resizable
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane (current: ${currentWidth}px)`} />
Expand All @@ -524,3 +467,37 @@ export const ResizablePaneWithControlledWidth: StoryFn = () => {
)
}
ResizablePaneWithControlledWidth.storyName = 'Resizable pane with controlled width (new API)'

export const ResizablePaneWithLocalStorage: StoryFn = () => {
const [width, setWidth] = useLocalStoragePaneWidth('page-layout-features-stories-local-storage', {
defaultWidth: defaultPaneWidth.medium,
minWidth: 256,
maxWidth: 600,
})

return (
<PageLayout>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Pane
width={width}
defaultWidth={defaultPaneWidth.medium}
minWidth={256}
maxWidth={600}
onWidthChange={setWidth}
resizable
aria-label="Side pane"
>
<Placeholder height={320} label={`Pane with localStorage (width: ${width}px)`} />
</PageLayout.Pane>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
<PageLayout.Footer>
<Placeholder height={64} label="Footer" />
</PageLayout.Footer>
</PageLayout>
)
}
ResizablePaneWithLocalStorage.storyName = 'Resizable pane with localStorage (useLocalStoragePaneWidth hook)'
Loading
Loading