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

`PageLayout.Sidebar` (and `SplitPageLayout.Sidebar`): add controlled-width support via `currentWidth` + `onResizeEnd`, matching the discriminated-union API already on `PageLayout.Pane`. The underlying `usePaneWidth` hook already supported these options; this wires them through the component's prop surface. Existing usage is unchanged — the props are opt-in and the uncontrolled (default or `widthStorageKey`-backed) behavior is preserved exactly.
Comment thread
mattcosta7 marked this conversation as resolved.
23 changes: 22 additions & 1 deletion packages/react/src/PageLayout/PageLayout.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
{
"id": "components-pagelayout-features--resizable-sidebar"
},
{
"id": "components-pagelayout-features--resizable-sidebar-without-persistence"
},
{
"id": "components-pagelayout-features--resizable-sidebar-with-custom-persistence"
},
{
"id": "components-pagelayout-features--sidebar-with-pane-resizable"
},
Expand Down Expand Up @@ -304,7 +310,22 @@
"name": "resizable",
"type": "boolean",
"defaultValue": "false",
"description": "When true, the sidebar may be resized by the user. Width is persisted to localStorage by default."
"description": "When true, the sidebar may be resized by the user. By default no persistence is applied; pass `widthStorageKey` to persist via localStorage, or use `onResizeEnd` + `currentWidth` for fully controlled persistence."
},
{
"name": "currentWidth",
"type": "number | undefined",
"description": "Current/controlled width value in pixels. Required when `onResizeEnd` is provided. Pass `undefined` when the persisted value has not loaded yet. The `width` prop still defines the default used when resetting (e.g., double-click)."
},
{
"name": "onResizeEnd",
"type": "(width: number) => void",
"description": "Callback fired when a resize operation ends (drag release or keyboard key up). When provided, replaces localStorage persistence. Requires `currentWidth`."
},
{
"name": "widthStorageKey",
"type": "string",
"description": "localStorage key used to persist the sidebar width across sessions. Only applies when `resizable` is `true` and no `onResizeEnd` callback is provided. When omitted, localStorage is not used."
},
{
"name": "padding",
Expand Down
84 changes: 84 additions & 0 deletions packages/react/src/PageLayout/PageLayout.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,90 @@ export const SidebarWithPaneResizable: StoryFn = () => (
</PageLayout>
)

export const ResizableSidebarWithoutPersistence: StoryFn = () => {
const [currentWidth, setCurrentWidth] = React.useState<number>(300)

return (
<PageLayout containerWidth="full">
<PageLayout.Sidebar
resizable
position="start"
currentWidth={currentWidth}
onResizeEnd={setCurrentWidth}
aria-label="Resizable sidebar (controlled)"
style={{height: 'auto'}}
width={{min: '200px', default: '300px', max: '600px'}}
>
<Placeholder height="100%" label={`Sidebar (controlled, width: ${currentWidth}px)`} />
</PageLayout.Sidebar>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
<PageLayout.Footer>
<Placeholder height={64} label="Footer" />
</PageLayout.Footer>
</PageLayout>
)
}
ResizableSidebarWithoutPersistence.storyName = 'Resizable sidebar without persistence'

export const ResizableSidebarWithCustomPersistence: StoryFn = () => {
const key = 'page-layout-features-stories-custom-persistence-sidebar-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())
}, [])

const handleWidthChange = (width: number) => {
setCurrentWidth(width)
localStorage.setItem(key, width.toString())
}

return (
<PageLayout containerWidth="full">
<PageLayout.Sidebar
resizable
position="start"
currentWidth={currentWidth}
onResizeEnd={handleWidthChange}
aria-label="Resizable sidebar (custom persistence)"
style={{height: 'auto'}}
width={{min: '200px', default: `${defaultPaneWidth.medium}px`, max: '600px'}}
>
<Placeholder height="100%" label={`Sidebar (width: ${currentWidth}px)`} />
</PageLayout.Sidebar>
<PageLayout.Header>
<Placeholder height={64} label="Header" />
</PageLayout.Header>
<PageLayout.Content>
<Placeholder height={640} label="Content" />
</PageLayout.Content>
<PageLayout.Footer>
<Placeholder height={64} label="Footer" />
</PageLayout.Footer>
</PageLayout>
)
}
ResizableSidebarWithCustomPersistence.storyName = 'Resizable sidebar with custom persistence'

export const StickySidebar: StoryFn = () => (
<PageLayout containerWidth="full">
<PageLayout.Sidebar sticky position="start" aria-label="Sticky sidebar">
Expand Down
36 changes: 36 additions & 0 deletions packages/react/src/PageLayout/PageLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,42 @@ describe('PageLayout', async () => {
fireEvent.keyDown(handle, {key: 'End'})
})

it('uses controlled `currentWidth` for the rendered --pane-width when provided', () => {
const onResizeEnd = vi.fn()
const {container} = render(
<PageLayout>
<PageLayout.Content>Content</PageLayout.Content>
<PageLayout.Sidebar resizable currentWidth={400} onResizeEnd={onResizeEnd}>
Sidebar
</PageLayout.Sidebar>
</PageLayout>,
)

const sidebar = container.querySelector<HTMLElement>('[class*="Sidebar"][data-resizable]')
expect(sidebar).not.toBeNull()
expect(sidebar!.style.getPropertyValue('--pane-width')).toBe('400px')
})

it('invokes onResizeEnd after a keyboard resize gesture', () => {
const onResizeEnd = vi.fn()
render(
<PageLayout>
<PageLayout.Content>Content</PageLayout.Content>
<PageLayout.Sidebar resizable currentWidth={300} onResizeEnd={onResizeEnd}>
Sidebar
</PageLayout.Sidebar>
</PageLayout>,
)

const handle = screen.getByRole('slider')
handle.focus()
fireEvent.keyDown(handle, {key: 'ArrowRight'})
fireEvent.keyUp(handle, {key: 'ArrowRight'})

expect(onResizeEnd).toHaveBeenCalledTimes(1)
expect(typeof onResizeEnd.mock.calls[0][0]).toBe('number')
})

it('respects different position values (start, end)', () => {
const {rerender, container} = render(
<PageLayout>
Expand Down
38 changes: 34 additions & 4 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ Pane.displayName = 'PageLayout.Pane'
// ----------------------------------------------------------------------------
// PageLayout.Sidebar

export type PageLayoutSidebarProps = {
export type PageLayoutSidebarBaseProps = {
/**
* A unique label for the sidebar region
*/
Expand All @@ -1030,7 +1030,10 @@ export type PageLayoutSidebarProps = {
position?: 'start' | 'end'

/**
* Width configuration for the sidebar
* Width configuration for the sidebar.
*
* When `resizable` is enabled, this defines the default width and constraints
* (min/max bounds for dragging). Use `currentWidth` to control the displayed width.
*/
width?: PaneWidth | CustomWidthOptions

Expand All @@ -1048,7 +1051,7 @@ export type PageLayoutSidebarProps = {

/**
* localStorage key used to persist the sidebar width across sessions.
* Only applies when `resizable` is `true`.
* Only applies when `resizable` is `true` and no `onResizeEnd` callback is provided.
* When omitted, localStorage is not used.
*/
widthStorageKey?: string
Expand Down Expand Up @@ -1092,6 +1095,28 @@ export type PageLayoutSidebarProps = {
style?: React.CSSProperties
}

export type PageLayoutSidebarProps = PageLayoutSidebarBaseProps &
(
| {
/**
* Callback fired when a resize operation ends (drag release or keyboard key up).
* When provided, this callback is used instead of localStorage persistence.
*/
onResizeEnd: (width: number) => void
/**
* Current/controlled width value in pixels.
* When provided, this is used as the current sidebar width instead of internal state.
* The `width` prop still defines the default used when resetting (e.g., double-click).
* Pass `undefined` when the persisted value has not loaded yet (e.g., async fetch).
*/
currentWidth: number | undefined
}
| {
onResizeEnd?: never
currentWidth?: never
}
)

const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayoutSidebarProps>>(
(
{
Expand All @@ -1100,6 +1125,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
position = 'start',
width = 'medium',
minWidth = 256,
currentWidth: controlledWidth,
onResizeEnd,
padding = 'none',
resizable = false,
widthStorageKey,
Expand Down Expand Up @@ -1134,6 +1161,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
handleRef,
contentWrapperRef: sidebarContentWrapperRef,
constrainToViewport: true,
onResizeEnd,
currentWidth: controlledWidth,
})

const mergedSidebarRef = useMergedRefs(forwardRef, sidebarRef)
Expand Down Expand Up @@ -1195,7 +1224,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLay
ref={mergedSidebarRef}
// Suppress hydration mismatch for --pane-width when localStorage
// provides a width that differs from the server-rendered default.
suppressHydrationWarning={resizable === true && !!widthStorageKey}
// Not needed when onResizeEnd is provided (localStorage isn't read).
suppressHydrationWarning={resizable === true && !!widthStorageKey && !onResizeEnd}
{...(hasOverflow ? overflowProps : {})}
{...labelProp}
{...(id && {id: sidebarId})}
Expand Down
Loading