@@ -361,6 +383,129 @@ export const WithCustomPaneHeading: StoryFn = () => (
)
+export const SidebarStart: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const SidebarEnd: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const ResizableSidebar: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const SidebarWithPaneResizable: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const StickySidebar: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const SidebarFullscreenWhenNarrow: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
export const ResizablePaneWithoutPersistence: StoryFn = () => {
const [currentWidth, setCurrentWidth] = React.useState
(defaultPaneWidth.medium)
diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css
index 5466fce1d67..48c7379cbdd 100644
--- a/packages/react/src/PageLayout/PageLayout.module.css
+++ b/packages/react/src/PageLayout/PageLayout.module.css
@@ -4,6 +4,8 @@
paneMaxWidthDiffBreakpoint: 1280;
/* Default value for --pane-max-width-diff below the breakpoint */
paneMaxWidthDiffDefault: 511;
+ /* Default value for --sidebar-max-width-diff (constant across all viewports) */
+ sidebarMaxWidthDiffDefault: 256;
}
.PageLayoutRoot {
@@ -29,6 +31,8 @@
--pane-width-large: 100%;
/* NOTE: This value is exported via :export for use in usePaneWidth.ts */
--pane-max-width-diff: 511px;
+ /* Sidebar uses a smaller diff since it doesn't need to reserve space for a file tree pane */
+ --sidebar-max-width-diff: 256px;
@media screen and (min-width: 768px) {
--pane-width-small: 240px;
@@ -596,7 +600,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 +746,134 @@
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: 1;
+ 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;
+ }
+
+ /* 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 */
+ @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));
+ }
+ }
+
+ /* 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 {
+ /* 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);
+ }
+
+ /* Hide divider in fullscreen mode at narrow viewport */
+ @media (--viewportRange-narrow) {
+ :where([data-when-narrow='fullscreen']) > & {
+ display: none;
+ }
+ }
+}
+
+.Sidebar[data-dragging='true'] {
+ contain: layout style paint;
+ pointer-events: none;
+}
diff --git a/packages/react/src/PageLayout/PageLayout.test.tsx b/packages/react/src/PageLayout/PageLayout.test.tsx
index 52bc50c9874..52be2038a59 100644
--- a/packages/react/src/PageLayout/PageLayout.test.tsx
+++ b/packages/react/src/PageLayout/PageLayout.test.tsx
@@ -272,4 +272,23 @@ describe('PageLayout', async () => {
expect(container.firstChild?.nodeName).toEqual('DIV')
})
})
+
+ describe('PageLayout.Sidebar', () => {
+ it('SidebarWrapper should allow shrinking to prevent overflow at narrow viewports', () => {
+ const {container} = render(
+
+ Content
+
+ Sidebar
+
+ ,
+ )
+
+ const sidebarWrapper = container.querySelector('[class*="SidebarWrapper"]')
+ expect(sidebarWrapper).not.toBeNull()
+
+ const style = getComputedStyle(sidebarWrapper!)
+ expect(style.flexShrink).toBe('1')
+ })
+ })
})
diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx
index c20e93049ff..f0dea3bd76c 100644
--- a/packages/react/src/PageLayout/PageLayout.tsx
+++ b/packages/react/src/PageLayout/PageLayout.tsx
@@ -18,6 +18,8 @@ import {
isPaneWidth,
ARROW_KEY_STEP,
type PaneWidthValue,
+ type PaneWidth,
+ type CustomWidthOptions,
} from './usePaneWidth'
import {setDraggingStyles, removeDraggingStyles} from './paneUtils'
@@ -46,12 +48,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},
})
// ----------------------------------------------------------------------------
@@ -66,7 +72,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
}
@@ -92,8 +98,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 {
@@ -102,12 +110,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}
@@ -124,7 +135,8 @@ const RootWrapper = memo(
padding,
children,
className,
- }: React.PropsWithChildren
>) => {
+ hasSidebar,
+ }: React.PropsWithChildren & {hasSidebar?: boolean}>) => {
return (
{children}
@@ -196,6 +209,114 @@ const VerticalDivider = memo>(
VerticalDivider.displayName = 'VerticalDivider'
+type SidebarDividerProps = {
+ position: 'start' | 'end'
+ divider: 'none' | 'line'
+ resizable: boolean
+ minPaneWidth: number
+ maxPaneWidth: number
+ currentWidth: number
+ currentWidthRef: React.MutableRefObject
+ handleRef: React.RefObject
+ sidebarRef: React.RefObject
+ dragStartClientXRef: React.MutableRefObject
+ dragStartWidthRef: React.MutableRefObject
+ dragMaxWidthRef: React.MutableRefObject
+ getMaxPaneWidth: () => number
+ getDefaultWidth: () => number
+ saveWidth: (width: number) => void
+}
+
+const SidebarDivider = memo(function SidebarDivider({
+ position,
+ divider,
+ resizable,
+ minPaneWidth,
+ maxPaneWidth,
+ currentWidth,
+ currentWidthRef,
+ handleRef,
+ sidebarRef,
+ dragStartClientXRef,
+ dragStartWidthRef,
+ dragMaxWidthRef,
+ getMaxPaneWidth,
+ getDefaultWidth,
+ saveWidth,
+}) {
+ const {columnGap} = React.useContext(PageLayoutContext)
+
+ 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) {
+ // For position='end': invert the delta so arrow keys feel natural
+ // ArrowRight should shrink (move divider right), ArrowLeft should expand
+ const delta = position === 'end' ? -value : 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}
+
+ )
+})
+
type DragHandleProps = {
/** Ref for imperative ARIA updates during drag */
handleRef: React.RefObject
@@ -918,6 +1039,237 @@ 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 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
+ */
+ 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',
+ sticky = false,
+ whenNarrow = 'default',
+ 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,
+ constrainToViewport: true,
+ })
+
+ 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 (
+
+ {position === 'end' && (
+
+ )}
+
+ {position === 'start' && (
+
+ )}
+
+ )
+ },
+)
+
+Sidebar.displayName = 'PageLayout.Sidebar'
+
+// ----------------------------------------------------------------------------
+// PageLayout.Footer
+
export type PageLayoutFooterProps = {
/**
* A unique label for the rendered contentinfo landmark
@@ -1014,10 +1366,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')
diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts
index 7c558522a6a..b31fcc38162 100644
--- a/packages/react/src/PageLayout/usePaneWidth.test.ts
+++ b/packages/react/src/PageLayout/usePaneWidth.test.ts
@@ -9,6 +9,7 @@ import {
updateAriaValues,
defaultPaneWidth,
DEFAULT_MAX_WIDTH_DIFF,
+ DEFAULT_SIDEBAR_MAX_WIDTH_DIFF,
SSR_DEFAULT_MAX_WIDTH,
ARROW_KEY_STEP,
} from './usePaneWidth'
@@ -1141,14 +1142,23 @@ describe('helper functions', () => {
})
describe('getPaneMaxWidthDiff', () => {
- it('should return default when element is null', () => {
+ it('should return default pane diff when element is null', () => {
expect(getPaneMaxWidthDiff(null)).toBe(DEFAULT_MAX_WIDTH_DIFF)
})
- it('should return default when CSS variable is not set', () => {
+ it('should return default sidebar diff when element is null and isSidebar is true', () => {
+ expect(getPaneMaxWidthDiff(null, true)).toBe(DEFAULT_SIDEBAR_MAX_WIDTH_DIFF)
+ })
+
+ it('should return default pane diff when CSS variable is not set', () => {
const element = document.createElement('div')
expect(getPaneMaxWidthDiff(element)).toBe(DEFAULT_MAX_WIDTH_DIFF)
})
+
+ it('should return default sidebar diff when CSS variable is not set and isSidebar is true', () => {
+ const element = document.createElement('div')
+ expect(getPaneMaxWidthDiff(element, true)).toBe(DEFAULT_SIDEBAR_MAX_WIDTH_DIFF)
+ })
})
describe('updateAriaValues', () => {
@@ -1187,6 +1197,7 @@ describe('helper functions', () => {
describe('constants', () => {
it('should export expected constants', () => {
expect(DEFAULT_MAX_WIDTH_DIFF).toBe(511)
+ expect(DEFAULT_SIDEBAR_MAX_WIDTH_DIFF).toBe(256)
expect(SSR_DEFAULT_MAX_WIDTH).toBe(600)
expect(ARROW_KEY_STEP).toBe(3)
expect(defaultPaneWidth).toEqual({small: 256, medium: 296, large: 320})
diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts
index b52b99fdd41..cc2fc844705 100644
--- a/packages/react/src/PageLayout/usePaneWidth.ts
+++ b/packages/react/src/PageLayout/usePaneWidth.ts
@@ -30,6 +30,12 @@ export type UsePaneWidthOptions = {
paneRef: React.RefObject
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
/** Callback fired when a resize operation ends (drag release or keyboard key up) */
onResizeEnd?: (width: number) => void
/** Current/controlled width value in pixels (used instead of internal state; default from `width` is still used for reset) */
@@ -62,6 +68,12 @@ export type UsePaneWidthResult = {
*/
export const DEFAULT_MAX_WIDTH_DIFF = Number(cssExports.paneMaxWidthDiffDefault)
+/**
+ * Default value for --sidebar-max-width-diff CSS variable.
+ * Unlike --pane-max-width-diff, this is constant across all viewport sizes.
+ */
+export const DEFAULT_SIDEBAR_MAX_WIDTH_DIFF = Number(cssExports.sidebarMaxWidthDiffDefault)
+
// --pane-max-width-diff changes at this breakpoint in PageLayout.module.css.
const DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT = Number(cssExports.paneMaxWidthDiffBreakpoint)
/**
@@ -100,14 +112,17 @@ export const getDefaultPaneWidth = (w: PaneWidthValue): number => {
}
/**
- * Gets the --pane-max-width-diff CSS variable value from a pane element.
- * This value is set by CSS media queries and controls the max pane width constraint.
+ * Gets the max-width-diff CSS variable value from a pane element.
+ * For sidebars, reads --sidebar-max-width-diff (constant across viewports).
+ * For panes, reads --pane-max-width-diff (changes at 1280px breakpoint).
* Note: This calls getComputedStyle which forces layout - cache the result when possible.
*/
-export function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number {
- if (!paneElement) return DEFAULT_MAX_WIDTH_DIFF
- const value = parseInt(getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff'), 10)
- return value > 0 ? value : DEFAULT_MAX_WIDTH_DIFF
+export function getPaneMaxWidthDiff(paneElement: HTMLElement | null, isSidebar = false): number {
+ const defaultValue = isSidebar ? DEFAULT_SIDEBAR_MAX_WIDTH_DIFF : DEFAULT_MAX_WIDTH_DIFF
+ const cssVar = isSidebar ? '--sidebar-max-width-diff' : '--pane-max-width-diff'
+ if (!paneElement) return defaultValue
+ const value = parseInt(getComputedStyle(paneElement).getPropertyValue(cssVar), 10)
+ return value > 0 ? value : defaultValue
}
// Helper to update ARIA slider attributes via direct DOM manipulation
@@ -171,6 +186,7 @@ export function usePaneWidth({
paneRef,
handleRef,
contentWrapperRef,
+ constrainToViewport = false,
onResizeEnd,
currentWidth: controlledWidth,
}: UsePaneWidthOptions): UsePaneWidthResult {
@@ -190,14 +206,20 @@ export function usePaneWidth({
})
// Cache the CSS variable value to avoid getComputedStyle during drag (causes layout thrashing)
// Updated on mount and resize when breakpoints might change
- const maxWidthDiffRef = React.useRef(DEFAULT_MAX_WIDTH_DIFF)
+ const maxWidthDiffRef = React.useRef(constrainToViewport ? DEFAULT_SIDEBAR_MAX_WIDTH_DIFF : 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])
const defaultWidth = useMemo(() => getDefaultPaneWidth(width), [width])
// --- State ---
@@ -324,7 +346,7 @@ export function usePaneWidth({
lastViewportWidth = currentViewportWidth
if (crossedBreakpoint) {
- maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current)
+ maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current, constrainToViewport)
}
const actualMax = getMaxPaneWidthRef.current()
@@ -352,14 +374,14 @@ export function usePaneWidth({
}
// Initial calculation on mount
- maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current)
+ maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current, constrainToViewport)
const initialMax = getMaxPaneWidthRef.current()
setMaxPaneWidth(initialMax)
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
@@ -419,7 +441,7 @@ export function usePaneWidth({
endResizeOptimizations()
window.removeEventListener('resize', handleResize)
}
- }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef, contentWrapperRef])
+ }, [resizable, customMaxWidth, constrainToViewport, minPaneWidth, paneRef, handleRef, contentWrapperRef])
return {
currentWidth,
diff --git a/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.module.css b/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.module.css
index c9fb3fe1076..1a3a57b08a3 100644
--- a/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.module.css
+++ b/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.module.css
@@ -31,3 +31,22 @@
font-size: var(--text-body-size-medium);
color: var(--fgColor-muted);
}
+
+.SidebarContent {
+ padding: var(--base-size-16);
+ background-color: var(--bgColor-muted);
+ border-radius: var(--borderRadius-medium);
+ height: 100%;
+}
+
+.SidebarHeading {
+ /* stylelint-disable-next-line primer/typography */
+ font-size: var(--text-body-size-medium);
+ font-weight: var(--base-text-weight-semibold);
+ margin-bottom: var(--base-size-8);
+}
+
+.SidebarText {
+ font-size: var(--text-body-size-small);
+ color: var(--fgColor-muted);
+}
diff --git a/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.tsx b/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.tsx
index 3660b498ed8..95270effde1 100644
--- a/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.tsx
+++ b/packages/react/src/SplitPageLayout/SplitPageLayout.features.stories.tsx
@@ -39,3 +39,194 @@ export const SettingsPage: StoryFn = () => (
)
+
+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.
+
+
+
Delete account
+
+
+
+ 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
+
+
+)
+
+export const WithStickySidebar: StoryFn = () => (
+
+
+
+ Sticky Sidebar
+
+ This sidebar stays fixed in the viewport as you scroll the page content.
+
+
+
+
+ Page Title
+
+
+
+ Scrollable Content
+
+ {Array.from({length: 20}).map((_, i) => (
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at enim id lorem tempus egestas a non ipsum.
+ Maecenas imperdiet ante quam, at varius lorem molestie vel.
+
+ ))}
+
+
+ Footer content
+
+
+)
+
+export const SidebarFullscreenWhenNarrow: StoryFn = () => (
+
+
+
+ Fullscreen on Narrow
+
+ Resize the viewport below 768px to see this sidebar expand to fill the entire screen.
+
+
+
+
+ Page Title
+
+
+
+ Main Content
+
+ This content is hidden behind the sidebar at narrow viewports.
+
+
+ Footer content
+
+
+)
diff --git a/packages/react/src/SplitPageLayout/SplitPageLayout.tsx b/packages/react/src/SplitPageLayout/SplitPageLayout.tsx
index 1e902129248..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,6 +23,7 @@ export const Root: React.FC> = pro
_slotsConfig={{
header: Header,
footer: Footer,
+ sidebar: Sidebar,
}}
{...props}
/>
@@ -85,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
@@ -108,5 +126,6 @@ export const SplitPageLayout = Object.assign(Root, {
Header,
Content,
Pane,
+ Sidebar,
Footer,
})