diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index bd0d76f5f31..e689d2b5f3b 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -66,6 +66,11 @@ export const PullRequestPage = () => ( + +
+

This is the sidebar content.

+
+
) @@ -301,6 +306,23 @@ export const ResizablePane: StoryFn = () => ( ) +export const ResizablePaneTwo: StoryFn = () => ( + + + + + + + + + + + + + + +) + export const ScrollContainerWithinPageLayoutPane: StoryFn = () => (
@@ -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..8a1cae8430e 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,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: 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; + } + + /* 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.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.ts b/packages/react/src/PageLayout/usePaneWidth.ts index b52b99fdd41..00a9240c63f 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) */ @@ -125,6 +131,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 (