From 2b1b003362024fe84827b411eec7223fa8c55672 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Tue, 10 Feb 2026 09:49:42 -0500 Subject: [PATCH 1/9] Add to `PageLayout` --- .../PageLayout.features.stories.tsx | 96 +++++++ .../src/PageLayout/PageLayout.module.css | 94 ++++++- packages/react/src/PageLayout/PageLayout.tsx | 262 +++++++++++++++++- 3 files changed, 446 insertions(+), 6 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index aef5c9346b0..5fff7cc9fe2 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -64,6 +64,11 @@ export const PullRequestPage = () => ( + +
+

This is the sidebar content.

+
+
) @@ -299,6 +304,23 @@ export const ResizablePane: StoryFn = () => ( ) +export const ResizablePaneTwo: StoryFn = () => ( + + + + + + + + + + + + + + +) + export const ScrollContainerWithinPageLayoutPane: StoryFn = () => (
@@ -358,3 +380,77 @@ export const WithCustomPaneHeading: StoryFn = () => ( ) + +export const SidebarStart: StoryFn = () => ( + + + + + + + + + + + + + + +) + +export const SidebarEnd: StoryFn = () => ( + + + + + + + + + + + + + + +) + +export const ResizableSidebar: StoryFn = () => ( + + + + + + + + + + + + + + + + + +) + +export const SidebarWithPaneResizable: StoryFn = () => ( + + + + + + + + + + + + + + + + + +) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 5466fce1d67..ae29f007429 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -596,7 +596,7 @@ width: 100%; @media screen and (min-width: 768px) { - /* + /* * --pane-max-width is set by JS on mount and updated on resize (debounced). * JS calculates viewport - margin to avoid scrollbar discrepancy with 100vw. */ @@ -742,3 +742,95 @@ contain: layout style paint; pointer-events: none; } + +/* Sidebar */ +.PageLayoutRoot:where([data-has-sidebar]) { + /* Note: Sidebar styles are only applied when the PageLayout has a Sidebar child + via `[data-has-sidebar`]` on the root element + */ + display: flex; + /* + Current layout structure for Sidebar support: + -- [Sidebar] | [Header + Content + Footer] | [Sidebar] -- + */ + flex-direction: row; +} + +.SidebarWrapper { + /* Current layout structure: + -- [Sidebar] | [Resizable Divider] -- + */ + display: flex; + flex-direction: row; + flex-shrink: 0; + height: 100%; + + &:where([data-is-hidden='true']) { + display: none; + } + + /* Position: start (left side) */ + &:where([data-position='start']) { + order: -1; + } + + /* Position: end (right side) */ + &:where([data-position='end']) { + order: 1; + } + + /* Narrow viewport */ + @media (--viewportRange-narrow) { + &:where([data-is-hidden-narrow='true']) { + display: none; + } + } + + /* Regular viewport */ + @media (--viewportRange-regular) { + &:where([data-is-hidden-regular='true']) { + display: none; + } + } + + /* Wide viewport */ + @media (--viewportRange-wide) { + &:where([data-is-hidden-wide='true']) { + display: none; + } + } +} + +.Sidebar { + width: var(--pane-width-size); + /* stylelint-disable-next-line primer/spacing */ + padding: var(--spacing); + height: 100%; + overflow: auto; + + &:where([data-resizable]) { + width: 100%; + + @media screen and (min-width: 768px) { + width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width)); + } + } +} + +.SidebarVerticalDivider { + /* Base position (non-responsive) */ + &:where([data-position='start']) { + /* stylelint-disable-next-line primer/spacing */ + margin-left: var(--spacing); + } + + &:where([data-position='end']) { + /* stylelint-disable-next-line primer/spacing */ + margin-right: var(--spacing); + } +} + +.Sidebar[data-dragging='true'] { + contain: layout style paint; + pointer-events: none; +} diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 753e353ae95..3cf2cebe8a6 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -47,12 +47,16 @@ const PageLayoutContext = React.createContext<{ columnGap: keyof typeof SPACING_MAP paneRef: React.RefObject contentWrapperRef: React.RefObject + sidebarRef: React.RefObject + sidebarContentWrapperRef: React.RefObject }>({ padding: 'normal', rowGap: 'normal', columnGap: 'normal', paneRef: {current: null}, contentWrapperRef: {current: null}, + sidebarRef: {current: null}, + sidebarContentWrapperRef: {current: null}, }) // ---------------------------------------------------------------------------- @@ -67,7 +71,7 @@ export type PageLayoutProps = { columnGap?: keyof typeof SPACING_MAP /** Private prop to allow SplitPageLayout to customize slot components */ - _slotsConfig?: Record<'header' | 'footer', React.ElementType> + _slotsConfig?: Record<'header' | 'footer' | 'sidebar', React.ElementType> className?: string style?: React.CSSProperties } @@ -93,8 +97,10 @@ const Root: React.FC> = ({ }) => { const paneRef = useRef(null) const contentWrapperRef = useRef(null) + const sidebarRef = useRef(null) + const sidebarContentWrapperRef = useRef(null) - const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer}) + const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer, sidebar: Sidebar}) const memoizedContextValue = React.useMemo(() => { return { @@ -103,12 +109,15 @@ const Root: React.FC> = ({ columnGap, paneRef, contentWrapperRef, + sidebarRef, + sidebarContentWrapperRef, } - }, [padding, rowGap, columnGap, paneRef, contentWrapperRef]) + }, [padding, rowGap, columnGap, paneRef, contentWrapperRef, sidebarRef, sidebarContentWrapperRef]) return ( - + + {slots.sidebar}
{slots.header}
{rest}
@@ -125,7 +134,8 @@ const RootWrapper = memo( padding, children, className, - }: React.PropsWithChildren>) => { + hasSidebar, + }: React.PropsWithChildren & {hasSidebar?: boolean}>) => { return (
{children}
@@ -867,6 +878,245 @@ Pane.displayName = 'PageLayout.Pane' // ---------------------------------------------------------------------------- // PageLayout.Footer +// ---------------------------------------------------------------------------- +// PageLayout.Sidebar + +export type PageLayoutSidebarProps = { + /** + * A unique label for the sidebar region + */ + 'aria-label'?: React.AriaAttributes['aria-label'] + + /** + * An id to an element which uniquely labels the sidebar region + */ + 'aria-labelledby'?: React.AriaAttributes['aria-labelledby'] + + /** + * Position of the sidebar relative to the page layout + * @default 'start' + */ + position?: 'start' | 'end' + + /** + * Width configuration for the sidebar + */ + width?: PaneWidth | CustomWidthOptions + + /** + * Minimum width of the sidebar when resizable + * @default 256 + */ + minWidth?: number + + /** + * Whether the sidebar can be resized + * @default false + */ + resizable?: boolean + + /** + * Storage key for persisting the sidebar width + * @default 'sidebarWidth' + */ + widthStorageKey?: string + + /** + * Padding inside the sidebar + */ + padding?: keyof typeof SPACING_MAP + + /** + * Divider style between sidebar and content + */ + divider?: 'none' | 'line' + + /** + * Whether the sidebar is hidden + */ + hidden?: boolean | ResponsiveValue + + /** + * Optional id for the sidebar element + */ + id?: string + + className?: string + style?: React.CSSProperties +} + +const Sidebar = React.forwardRef>( + ( + { + 'aria-label': label, + 'aria-labelledby': labelledBy, + position = 'start', + width = 'medium', + minWidth = 256, + padding = 'none', + resizable = false, + widthStorageKey = 'sidebarWidth', + divider = 'none', + hidden: responsiveHidden = false, + children, + id, + className, + style, + }, + forwardRef, + ) => { + const {columnGap, sidebarRef, sidebarContentWrapperRef} = React.useContext(PageLayoutContext) + + // Ref to the drag handle for updating ARIA attributes + const handleRef = React.useRef(null) + + // Cache drag start values to calculate relative delta during drag + const dragStartClientXRef = React.useRef(0) + const dragStartWidthRef = React.useRef(0) + const dragMaxWidthRef = React.useRef(0) + + const {currentWidth, currentWidthRef, minPaneWidth, maxPaneWidth, getMaxPaneWidth, saveWidth, getDefaultWidth} = + usePaneWidth({ + width, + minWidth, + resizable, + widthStorageKey, + paneRef: sidebarRef, + handleRef, + contentWrapperRef: sidebarContentWrapperRef, + }) + + useRefObjectAsForwardedRef(forwardRef, sidebarRef) + + const hasOverflow = useOverflow(sidebarRef) + + const sidebarId = useId(id) + + const labelProp: {'aria-labelledby'?: string; 'aria-label'?: string} = {} + if (hasOverflow) { + warning( + label === undefined && labelledBy === undefined, + 'The has overflow and `aria-label` or `aria-labelledby` has not been set. ' + + 'Please provide `aria-label` or `aria-labelledby` to in order to label this ' + + 'region.', + ) + + if (labelledBy) { + labelProp['aria-labelledby'] = labelledBy + } else if (label) { + labelProp['aria-label'] = label + } + } + + return ( +
+ + + {resizable ? ( + { + dragStartClientXRef.current = clientX + dragStartWidthRef.current = + sidebarRef.current?.getBoundingClientRect().width ?? currentWidthRef.current! + dragMaxWidthRef.current = getMaxPaneWidth() + }} + onDrag={(value, isKeyboard) => { + const maxWidth = isKeyboard ? getMaxPaneWidth() : dragMaxWidthRef.current + + if (isKeyboard) { + const delta = value + const newWidth = Math.max(minPaneWidth, Math.min(maxWidth, currentWidthRef.current! + delta)) + if (newWidth !== currentWidthRef.current) { + currentWidthRef.current = newWidth + sidebarRef.current?.style.setProperty('--pane-width', `${newWidth}px`) + updateAriaValues(handleRef.current, {current: newWidth, max: maxWidth}) + } + } else { + if (sidebarRef.current) { + const deltaX = value - dragStartClientXRef.current + // For position='end': cursor moving left (negative delta) increases width + // For position='start': cursor moving right (positive delta) increases width + const directedDelta = position === 'end' ? -deltaX : deltaX + const newWidth = dragStartWidthRef.current + directedDelta + + const clampedWidth = Math.max(minPaneWidth, Math.min(maxWidth, newWidth)) + + if (Math.round(clampedWidth) !== Math.round(currentWidthRef.current!)) { + sidebarRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) + currentWidthRef.current = clampedWidth + updateAriaValues(handleRef.current, {current: Math.round(clampedWidth), max: maxWidth}) + } + } + } + }} + onDragEnd={() => { + saveWidth(currentWidthRef.current!) + }} + onDoubleClick={() => { + const resetWidth = getDefaultWidth() + if (sidebarRef.current) { + sidebarRef.current.style.setProperty('--pane-width', `${resetWidth}px`) + currentWidthRef.current = resetWidth + updateAriaValues(handleRef.current, {current: resetWidth}) + } + saveWidth(resetWidth) + }} + /> + ) : null} + +
+ ) + }, +) + +Sidebar.displayName = 'PageLayout.Sidebar' + +// ---------------------------------------------------------------------------- +// PageLayout.Footer + export type PageLayoutFooterProps = { /** * A unique label for the rendered contentinfo landmark @@ -963,10 +1213,12 @@ export const PageLayout = Object.assign(Root, { Header, Content, Pane: Pane as WithSlotMarker, + Sidebar: Sidebar as WithSlotMarker, Footer, }) Header.__SLOT__ = Symbol('PageLayout.Header') Content.__SLOT__ = Symbol('PageLayout.Content') ;(Pane as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Pane') +;(Sidebar as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Sidebar') Footer.__SLOT__ = Symbol('PageLayout.Footer') From 32a53932359449dad8284f022e66fc6f9332c86f Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Tue, 10 Feb 2026 10:02:46 -0500 Subject: [PATCH 2/9] Add to slots --- packages/react/src/SplitPageLayout/SplitPageLayout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/SplitPageLayout/SplitPageLayout.tsx b/packages/react/src/SplitPageLayout/SplitPageLayout.tsx index 1e902129248..a7f091fddd6 100644 --- a/packages/react/src/SplitPageLayout/SplitPageLayout.tsx +++ b/packages/react/src/SplitPageLayout/SplitPageLayout.tsx @@ -22,6 +22,7 @@ export const Root: React.FC> = pro _slotsConfig={{ header: Header, footer: Footer, + sidebar: Pane, }} {...props} /> From e06ca2cabc1bfe4c6ecb0796df8d9dd9ca051104 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Tue, 10 Feb 2026 17:18:04 -0500 Subject: [PATCH 3/9] Add to `SplitPageLayout` --- packages/react/src/PageLayout/PageLayout.tsx | 1 + ...plitPageLayout.features.stories.module.css | 19 +++ .../SplitPageLayout.features.stories.tsx | 136 ++++++++++++++++++ .../src/SplitPageLayout/SplitPageLayout.tsx | 20 ++- 4 files changed, 175 insertions(+), 1 deletion(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 3cf2cebe8a6..2e4154fdc36 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -958,6 +958,7 @@ const Sidebar = React.forwardRef = () => ( ) + +export const WithSidebarStart: StoryFn = () => ( + + +
+ Sidebar + + This sidebar spans the full height of the layout, adjacent to the header, content, and footer. + +
+
+ + Page Title + + + + Profile + + Account + + Emails + Notifications + + + + + Danger zone + +
+
+ Delete account + + Are you sure you don't want to just downgrade your account to a free account? We won't charge your + credit card anymore. + +
+ +
+
+ + Footer content + +
+) + +export const WithSidebarEnd: StoryFn = () => ( + + +
+ Inspector + + This sidebar is positioned at the end (right side) and spans the full height. + +
+
+ + Page Title + + + + Profile + + Account + + Emails + Notifications + + + + + Account Settings + + Main content area + + + Footer content + +
+) + +export const WithResizableSidebar: StoryFn = () => ( + + +
+ Resizable Sidebar + + Drag the edge to resize this sidebar. The width will be persisted across sessions. + +
+
+ + Page Title + + + + Main Content + + This layout has a resizable sidebar that can be dragged to adjust its width. + + + Footer content + +
+) + +export const WithSidebarAndResizablePane: StoryFn = () => ( + + +
+ Sidebar + Full-height resizable sidebar +
+
+ + Page Title + + +
+ Details Pane + This pane is also resizable and sits beside the content. +
+
+ + + Main Content + + + This layout demonstrates using both a full-height sidebar and a resizable pane together. The sidebar spans the + entire height while the pane sits adjacent to the content area only. + + + + Footer content + +
+) diff --git a/packages/react/src/SplitPageLayout/SplitPageLayout.tsx b/packages/react/src/SplitPageLayout/SplitPageLayout.tsx index a7f091fddd6..8eeb4a9203d 100644 --- a/packages/react/src/SplitPageLayout/SplitPageLayout.tsx +++ b/packages/react/src/SplitPageLayout/SplitPageLayout.tsx @@ -4,6 +4,7 @@ import type { PageLayoutFooterProps, PageLayoutHeaderProps, PageLayoutPaneProps, + PageLayoutSidebarProps, } from '../PageLayout' import {PageLayout} from '../PageLayout' @@ -22,7 +23,7 @@ export const Root: React.FC> = pro _slotsConfig={{ header: Header, footer: Footer, - sidebar: Pane, + sidebar: Sidebar, }} {...props} /> @@ -86,6 +87,22 @@ export const Pane: React.FC> = } Pane.displayName = 'SplitPageLayout.Pane' +// ---------------------------------------------------------------------------- +// SplitPageLayout.Sidebar + +export type SplitPageLayoutSidebarProps = PageLayoutSidebarProps + +export const Sidebar: React.FC> = ({ + position = 'start', + padding = 'normal', + divider = 'line', + ...props +}) => { + return +} + +Sidebar.displayName = 'SplitPageLayout.Sidebar' + // ---------------------------------------------------------------------------- // SplitPageLayout.Footer @@ -109,5 +126,6 @@ export const SplitPageLayout = Object.assign(Root, { Header, Content, Pane, + Sidebar, Footer, }) From c8fe36f583d65ebb3c1ef993c5477cb6943aede9 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Wed, 11 Feb 2026 02:07:24 -0500 Subject: [PATCH 4/9] Add new props --- .../PageLayout.features.stories.tsx | 34 ++++++++++++ .../src/PageLayout/PageLayout.module.css | 39 +++++++++++++ packages/react/src/PageLayout/PageLayout.tsx | 20 ++++++- .../SplitPageLayout.features.stories.tsx | 55 +++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index 5fff7cc9fe2..5e47f570217 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -454,3 +454,37 @@ export const SidebarWithPaneResizable: StoryFn = () => ( ) + +export const StickySidebar: StoryFn = () => ( + + + + + + + + + + + + + + +) + +export const SidebarFullscreenWhenNarrow: StoryFn = () => ( + + + + + + + + + + + + + + +) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index ae29f007429..8a1cae8430e 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -779,11 +779,33 @@ order: 1; } + /* Sticky sidebar */ + &:where([data-sticky]) { + position: sticky; + top: 0; + height: 100vh; + } + /* Narrow viewport */ @media (--viewportRange-narrow) { &:where([data-is-hidden-narrow='true']) { display: none; } + + /* Fullscreen mode at narrow viewport */ + &:where([data-when-narrow='fullscreen']) { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + min-width: unset; + max-width: unset; + border-radius: 0; + /* stylelint-disable-next-line primer/spacing */ + z-index: 999; + background-color: var(--bgColor-default); + } } /* Regular viewport */ @@ -815,6 +837,16 @@ width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width)); } } + + /* In fullscreen mode at narrow, sidebar takes full available space */ + @media (--viewportRange-narrow) { + :where([data-when-narrow='fullscreen']) > & { + width: 100%; + height: 100%; + min-width: unset; + max-width: unset; + } + } } .SidebarVerticalDivider { @@ -828,6 +860,13 @@ /* stylelint-disable-next-line primer/spacing */ margin-right: var(--spacing); } + + /* Hide divider in fullscreen mode at narrow viewport */ + @media (--viewportRange-narrow) { + :where([data-when-narrow='fullscreen']) > & { + display: none; + } + } } .Sidebar[data-dragging='true'] { diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 2e4154fdc36..815ba5b4df6 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -931,6 +931,21 @@ export type PageLayoutSidebarProps = { */ divider?: 'none' | 'line' + /** + * Whether the sidebar sticks to the viewport when scrolling. + * When enabled, the sidebar uses `position: sticky` with `top: 0` and `height: 100vh`. + * @default false + */ + sticky?: boolean + + /** + * Controls sidebar behavior at narrow viewport widths (below 768px). + * - `'default'`: the sidebar retains its normal inline layout. + * - `'fullscreen'`: the sidebar expands to cover the full viewport like a dialog overlay. + * @default 'default' + */ + whenNarrow?: 'default' | 'fullscreen' + /** * Whether the sidebar is hidden */ @@ -957,8 +972,9 @@ const Sidebar = React.forwardRef
) }, From 11fc60c7410040e9a84bcbaaf42920bf79f8b934 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Thu, 12 Feb 2026 23:53:47 -0500 Subject: [PATCH 6/9] Fix sidebar expanding width --- .../PageLayout.features.stories.tsx | 10 +++++-- packages/react/src/PageLayout/PageLayout.tsx | 1 + packages/react/src/PageLayout/usePaneWidth.ts | 27 ++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index 5e47f570217..bf6680e0edf 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -437,13 +437,19 @@ export const ResizableSidebar: StoryFn = () => ( export const SidebarWithPaneResizable: StoryFn = () => ( - + - + diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 6b7f53fa495..585d0b883a1 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -1109,6 +1109,7 @@ const Sidebar = React.forwardRef handleRef: React.RefObject contentWrapperRef: React.RefObject + /** + * When true, custom max width values are capped to the viewport-based max. + * This prevents overflow in non-wrapping flex layouts (e.g., Sidebar). + * @default false + */ + constrainToViewport?: boolean } export type UsePaneWidthResult = { @@ -132,6 +138,7 @@ export function usePaneWidth({ paneRef, handleRef, contentWrapperRef, + constrainToViewport = false, }: UsePaneWidthOptions): UsePaneWidthResult { // Derive constraints from width configuration const isCustomWidth = isCustomWidthOptions(width) @@ -142,12 +149,18 @@ export function usePaneWidth({ // Updated on mount and resize when breakpoints might change const maxWidthDiffRef = React.useRef(DEFAULT_MAX_WIDTH_DIFF) - // Calculate max width constraint - for custom widths this is fixed, otherwise viewport-dependent + // Calculate max width constraint - for custom widths this is capped to viewport bounds + // when constrainToViewport is set (e.g., Sidebar), otherwise it uses the custom max directly. + // For preset widths, max is always viewport-dependent. const getMaxPaneWidth = React.useCallback(() => { - if (customMaxWidth !== null) return customMaxWidth const viewportWidth = window.innerWidth - return viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth - }, [customMaxWidth, minPaneWidth]) + const viewportMax = + viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth + if (customMaxWidth !== null) { + return constrainToViewport ? Math.min(customMaxWidth, viewportMax) : customMaxWidth + } + return viewportMax + }, [customMaxWidth, minPaneWidth, constrainToViewport]) // --- State --- // Current width for React renders (ARIA attributes). Updates go through saveWidth() or clamp on resize. @@ -263,8 +276,8 @@ export function usePaneWidth({ paneRef.current?.style.setProperty('--pane-max-width', `${initialMax}px`) updateAriaValues(handleRef.current, {min: minPaneWidth, max: initialMax, current: currentWidthRef.current}) - // For custom widths, max is fixed - no need to listen to resize - if (customMaxWidth !== null) return + // For custom widths that aren't viewport-constrained, max is fixed - no need to listen to resize + if (customMaxWidth !== null && !constrainToViewport) return // Throttle approach for window resize - provides immediate visual feedback for small DOMs // while still limiting update frequency @@ -324,7 +337,7 @@ export function usePaneWidth({ endResizeOptimizations() window.removeEventListener('resize', handleResize) } - }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef, contentWrapperRef]) + }, [resizable, customMaxWidth, constrainToViewport, minPaneWidth, paneRef, handleRef, contentWrapperRef]) return { currentWidth, From 2cc07e4940936184dcea07ea007d4ad990ac22d7 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 13 Feb 2026 09:23:17 -0500 Subject: [PATCH 7/9] Add onto `usePaneWidth` --- packages/react/src/PageLayout/usePaneWidth.ts | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 69587be1848..5e2f7f4f515 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -122,6 +122,35 @@ export const updateAriaValues = ( } } +/** + * Measures the available space for a pane/sidebar by examining its parent flex + * container and subtracting the widths of sibling elements. + * + * Navigation: paneElement (