diff --git a/.changeset/pagelayout-sidebar-controlled-width.md b/.changeset/pagelayout-sidebar-controlled-width.md new file mode 100644 index 00000000000..fb8a5174e1c --- /dev/null +++ b/.changeset/pagelayout-sidebar-controlled-width.md @@ -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. diff --git a/packages/react/src/PageLayout/PageLayout.docs.json b/packages/react/src/PageLayout/PageLayout.docs.json index 11eacd89da9..5174c42f096 100644 --- a/packages/react/src/PageLayout/PageLayout.docs.json +++ b/packages/react/src/PageLayout/PageLayout.docs.json @@ -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" }, @@ -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", diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index 910c3fc242a..8a0ce8ee627 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -451,6 +451,90 @@ export const SidebarWithPaneResizable: StoryFn = () => ( ) +export const ResizableSidebarWithoutPersistence: StoryFn = () => { + const [currentWidth, setCurrentWidth] = React.useState(300) + + return ( + + + + + + + + + + + + + + + ) +} +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(getInitialWidth) + useIsomorphicLayoutEffect(() => { + setCurrentWidth(getInitialWidth()) + }, []) + + const handleWidthChange = (width: number) => { + setCurrentWidth(width) + localStorage.setItem(key, width.toString()) + } + + return ( + + + + + + + + + + + + + + + ) +} +ResizableSidebarWithCustomPersistence.storyName = 'Resizable sidebar with custom persistence' + export const StickySidebar: StoryFn = () => ( diff --git a/packages/react/src/PageLayout/PageLayout.test.tsx b/packages/react/src/PageLayout/PageLayout.test.tsx index 2166339f04c..08e2cb0aaad 100644 --- a/packages/react/src/PageLayout/PageLayout.test.tsx +++ b/packages/react/src/PageLayout/PageLayout.test.tsx @@ -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( + + Content + + Sidebar + + , + ) + + const sidebar = container.querySelector('[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( + + Content + + Sidebar + + , + ) + + 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( diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 3234e11e127..041bb73cc6a 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -1012,7 +1012,7 @@ Pane.displayName = 'PageLayout.Pane' // ---------------------------------------------------------------------------- // PageLayout.Sidebar -export type PageLayoutSidebarProps = { +export type PageLayoutSidebarBaseProps = { /** * A unique label for the sidebar region */ @@ -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 @@ -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 @@ -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>( ( { @@ -1100,6 +1125,8 @@ const Sidebar = React.forwardRef