diff --git a/.changeset/serious-pots-confess.md b/.changeset/serious-pots-confess.md
new file mode 100644
index 00000000000..336a503595a
--- /dev/null
+++ b/.changeset/serious-pots-confess.md
@@ -0,0 +1,13 @@
+---
+"@primer/react": minor
+---
+
+Add a `sticky` prop to the `PageLayout.Pane` component that provides a convenient way for you to make side panes sticky:
+
+```diff
+
+- ...
++ ...
+ ...
+
+```
diff --git a/docs/content/PageLayout.mdx b/docs/content/PageLayout.mdx
index 577016ad206..75224d6bda2 100644
--- a/docs/content/PageLayout.mdx
+++ b/docs/content/PageLayout.mdx
@@ -147,6 +147,27 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
```
+### With a sticky pane
+
+```jsx live
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
## Props
### PageLayout
@@ -310,6 +331,12 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
defaultValue="'medium'"
description="The width of the pane."
/>
+
+
(
+const Template: Story = args => (
(
columnGap={args.columnGap}
sx={args.sx}
>
- {args['Show header?'] ? (
-
-
+ {args['Render header?'] ? (
+
+
) : null}
-
-
+
+
- {args['Show pane?'] ? (
+ {args['Render pane?'] ? (
-
+
) : null}
- {args['Show footer?'] ? (
-
-
+ {args['Render footer?'] ? (
+
+
) : null}
)
-Playground.argTypes = {
- 'Show header?': {
- type: 'boolean',
- defaultValue: true,
- table: {
- category: 'Header'
- }
- },
- 'Header.divider': {
- type: {
- name: 'enum',
- value: ['none', 'line']
- },
- defaultValue: 'none',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Header',
- defaultValue: {
- summary: '"none"'
- }
- }
- },
- 'Header.dividerWhenNarrow': {
- type: {
- name: 'enum',
- value: ['inherit', 'none', 'line', 'filled']
- },
- defaultValue: 'inherit',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Header',
- defaultValue: {
- summary: '"inherit"'
- }
- }
- },
- 'Content.width': {
- type: {
- name: 'enum',
- value: ['full', 'medium', 'large', 'xlarge']
- },
- defaultValue: 'full',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Content',
- defaultValue: {
- summary: '"full"'
- }
- }
- },
- 'Show pane?': {
- type: 'boolean',
- defaultValue: true,
- table: {
- category: 'Pane'
- }
- },
- 'Pane.position': {
- type: {
- name: 'enum',
- value: ['start', 'end']
- },
- defaultValue: 'end',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Pane',
- defaultValue: {
- summary: '"end"'
- }
- }
- },
- 'Pane.positionWhenNarrow': {
- type: {
- name: 'enum',
- value: ['inherit', 'start', 'end']
- },
- defaultValue: 'inherit',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Pane',
- defaultValue: {
- summary: '"inherit"'
- }
- }
- },
- 'Pane.width': {
- type: {
- name: 'enum',
- value: ['small', 'medium', 'large']
- },
- defaultValue: 'medium',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Pane',
- defaultValue: {
- summary: '"medium"'
- }
- }
- },
- 'Pane.divider': {
- type: {
- name: 'enum',
- value: ['none', 'line']
- },
- defaultValue: 'none',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Pane',
- defaultValue: {
- summary: '"none"'
- }
- }
- },
- 'Pane.dividerWhenNarrow': {
- type: {
- name: 'enum',
- value: ['inherit', 'none', 'line', 'filled']
- },
- defaultValue: 'inherit',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Pane',
- defaultValue: {
- summary: '"inherit"'
- }
- }
- },
- 'Show footer?': {
- type: 'boolean',
- defaultValue: true,
- table: {
- category: 'Footer'
- }
- },
- 'Footer.divider': {
- type: {
- name: 'enum',
- value: ['none', 'line']
- },
- defaultValue: 'none',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Footer',
- defaultValue: {
- summary: '"none"'
- }
- }
- },
- 'Footer.dividerWhenNarrow': {
- type: {
- name: 'enum',
- value: ['inherit', 'none', 'line', 'filled']
- },
- defaultValue: 'inherit',
- control: {
- type: 'radio'
- },
- table: {
- category: 'Footer',
- defaultValue: {
- summary: '"inherit"'
- }
- }
- }
-}
+export const Default = Template.bind({})
+
+export const SplitPage = Template.bind({})
-Playground.args = {
- containerWidth: 'xlarge',
- padding: 'normal',
- rowGap: 'normal',
- columnGap: 'normal'
+SplitPage.args = {
+ containerWidth: 'full',
+ padding: 'none',
+ rowGap: 'none',
+ columnGap: 'none',
+ 'Header.padding': 'normal',
+ 'Header.divider.regular': 'line',
+ 'Header.divider.narrow': 'line',
+ 'Header.divider.wide': 'line',
+ 'Content.padding': 'normal',
+ 'Content.width': 'xlarge',
+ 'Pane.sticky': true,
+ 'Pane.position.narrow': 'start',
+ 'Pane.position.regular': 'start',
+ 'Pane.position.wide': 'start',
+ 'Pane.padding': 'normal',
+ 'Pane.divider.narrow': 'line',
+ 'Pane.divider.regular': 'line',
+ 'Pane.divider.wide': 'line',
+ 'Footer.padding': 'normal',
+ 'Footer.divider.regular': 'line',
+ 'Footer.divider.narrow': 'line',
+ 'Footer.divider.wide': 'line',
+
+ 'Pane placeholder height': 1200,
+ 'Content placeholder height': 2400
}
export const PullRequestPage = () => (
@@ -349,6 +555,120 @@ export const SettingsPage = () => (
)
-// TODO: discussions page example
+export const StickyPane: Story = args => (
+
+
+
+
+
+
+ {Array.from({length: args.numParagraphsInContent}).map((_, i) => {
+ const testId = `content${i}`
+ return (
+
+
+ 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. Sed at eros consequat, varius tellus
+ et, auctor felis. Donec pulvinar lacinia urna nec commodo. Phasellus at imperdiet risus. Donec sit amet
+ massa purus. Nunc sem lectus, bibendum a sapien nec, tristique tempus felis. Ut porttitor auctor tellus
+ in imperdiet. Ut blandit tincidunt augue, quis fringilla nunc tincidunt sed. Vestibulum auctor euismod
+ nisi. Nullam tincidunt est in mi tincidunt dictum. Sed consectetur aliquet velit ut ornare.
+
+
+ )
+ })}
+
+
+
+
+ {Array.from({length: args.numParagraphsInPane}).map((_, i) => {
+ const testId = `paragraph${i}`
+ return (
+
+
+ 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. Sed at eros consequat, varius tellus
+ et, auctor felis. Donec pulvinar lacinia urna nec commodo. Phasellus at imperdiet risus. Donec sit amet
+ massa purus.
+
+
+ )
+ })}
+
+
+
+
+
+
+)
+
+StickyPane.argTypes = {
+ sticky: {
+ type: 'boolean',
+ defaultValue: true
+ },
+ numParagraphsInPane: {
+ type: 'number',
+ defaultValue: 10
+ },
+ numParagraphsInContent: {
+ type: 'number',
+ defaultValue: 30
+ }
+}
+
+export const NestedScrollContainer: Story = args => (
+
+
+
+
+
+
+
+
+
+ {Array.from({length: args.numParagraphsInContent}).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. Sed at eros consequat, varius tellus
+ et, auctor felis. Donec pulvinar lacinia urna nec commodo. Phasellus at imperdiet risus. Donec sit amet
+ massa purus. Nunc sem lectus, bibendum a sapien nec, tristique tempus felis. Ut porttitor auctor tellus
+ in imperdiet. Ut blandit tincidunt augue, quis fringilla nunc tincidunt sed. Vestibulum auctor euismod
+ nisi. Nullam tincidunt est in mi tincidunt dictum. Sed consectetur aliquet velit ut ornare.
+
+ ))}
+
+
+
+
+ {Array.from({length: args.numParagraphsInPane}).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. Sed at eros consequat, varius tellus
+ et, auctor felis. Donec pulvinar lacinia urna nec commodo. Phasellus at imperdiet risus. Donec sit amet
+ massa purus.
+
+ ))}
+
+
+
+
+
+
+
+
+
+)
+
+NestedScrollContainer.argTypes = {
+ numParagraphsInPane: {
+ type: 'number',
+ defaultValue: 10
+ },
+ numParagraphsInContent: {
+ type: 'number',
+ defaultValue: 30
+ }
+}
export default meta
diff --git a/src/PageLayout/PageLayout.test.tsx b/src/PageLayout/PageLayout.test.tsx
index 171bb6216f0..83179c325c7 100644
--- a/src/PageLayout/PageLayout.test.tsx
+++ b/src/PageLayout/PageLayout.test.tsx
@@ -1,6 +1,7 @@
import React from 'react'
import {act, render} from '@testing-library/react'
import MatchMediaMock from 'jest-matchmedia-mock'
+import 'react-intersection-observer/test-utils'
import {ThemeProvider} from '..'
import {viewportRanges} from '../hooks/useResponsiveValue'
import {PageLayout} from './PageLayout'
diff --git a/src/PageLayout/PageLayout.tsx b/src/PageLayout/PageLayout.tsx
index 5825f0a0510..b8fc977d989 100644
--- a/src/PageLayout/PageLayout.tsx
+++ b/src/PageLayout/PageLayout.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+import {useStickyPaneHeight} from './useStickyPaneHeight'
import {Box} from '..'
import {isResponsiveValue, ResponsiveValue, useResponsiveValue} from '../hooks/useResponsiveValue'
import {BetterSystemStyleObject, merge, SxProp} from '../sx'
@@ -21,6 +22,10 @@ const PageLayoutContext = React.createContext<{
padding: keyof typeof SPACING_MAP
rowGap: keyof typeof SPACING_MAP
columnGap: keyof typeof SPACING_MAP
+ enableStickyPane?: () => void
+ disableStickyPane?: () => void
+ contentTopRef?: (node?: Element | null | undefined) => void
+ contentBottomRef?: (node?: Element | null | undefined) => void
}>({
padding: 'normal',
rowGap: 'normal',
@@ -55,9 +60,29 @@ const Root: React.FC> = ({
children,
sx = {}
}) => {
+ const {rootRef, enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef, stickyPaneHeight} =
+ useStickyPaneHeight()
return (
-
- ({padding: SPACING_MAP[padding]}, sx)}>
+
+ ({padding: SPACING_MAP[padding]}, sx)}
+ >
> = ({
sx = {}
}) => {
const isHidden = useResponsiveValue(hidden, false)
+ const {contentTopRef, contentBottomRef} = React.useContext(PageLayoutContext)
return (
(
{
+ display: 'flex',
+ flexDirection: 'column',
order: REGION_ORDER.content,
// Set flex-basis to 0% to allow flex-grow to control the width of the content region.
// Without this, the content region could wrap onto a different line
@@ -272,9 +300,23 @@ const Content: React.FC> = ({
sx
)}
>
-
+ {/* Track the top of the content region so we can calculate the height of the pane region */}
+
+
+
{children}
+
+ {/* Track the bottom of the content region so we can calculate the height of the pane region */}
+
)
}
@@ -319,6 +361,7 @@ export type PageLayoutPaneProps = {
* ```
*/
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
+ sticky?: boolean
hidden?: boolean | ResponsiveValue
} & SxProp
@@ -334,33 +377,45 @@ const paneWidths = {
}
const Pane: React.FC> = ({
- position = 'end',
+ position: responsivePosition = 'end',
positionWhenNarrow = 'inherit',
width = 'medium',
padding = 'none',
- divider = 'none',
+ divider: responsiveDivider = 'none',
dividerWhenNarrow = 'inherit',
- hidden = false,
+ sticky = false,
+ hidden: responsiveHidden = false,
children,
sx = {}
}) => {
// Combine position and positionWhenNarrow for backwards compatibility
const positionProp =
- !isResponsiveValue(position) && positionWhenNarrow !== 'inherit'
- ? {regular: position, narrow: positionWhenNarrow}
- : position
+ !isResponsiveValue(responsivePosition) && positionWhenNarrow !== 'inherit'
+ ? {regular: responsivePosition, narrow: positionWhenNarrow}
+ : responsivePosition
- const responsivePosition = useResponsiveValue(positionProp, 'end')
+ const position = useResponsiveValue(positionProp, 'end')
// Combine divider and dividerWhenNarrow for backwards compatibility
const dividerProp =
- !isResponsiveValue(divider) && dividerWhenNarrow !== 'inherit'
- ? {regular: divider, narrow: dividerWhenNarrow}
- : divider
+ !isResponsiveValue(responsiveDivider) && dividerWhenNarrow !== 'inherit'
+ ? {regular: responsiveDivider, narrow: dividerWhenNarrow}
+ : responsiveDivider
const dividerVariant = useResponsiveValue(dividerProp, 'none')
- const isHidden = useResponsiveValue(hidden, false)
- const {rowGap, columnGap} = React.useContext(PageLayoutContext)
+
+ const isHidden = useResponsiveValue(responsiveHidden, false)
+
+ const {rowGap, columnGap, enableStickyPane, disableStickyPane} = React.useContext(PageLayoutContext)
+
+ React.useEffect(() => {
+ if (sticky) {
+ enableStickyPane?.()
+ } else {
+ disableStickyPane?.()
+ }
+ }, [sticky, enableStickyPane, disableStickyPane])
+
return (
> = ({
sx={(theme: any) =>
merge(
{
- order: panePositions[responsivePosition],
+ // Narrow viewports
display: isHidden ? 'none' : 'flex',
- flexDirection: responsivePosition === 'end' ? 'column' : 'column-reverse',
+ order: panePositions[position],
width: '100%',
marginX: 0,
- [responsivePosition === 'end' ? 'marginTop' : 'marginBottom']: SPACING_MAP[rowGap],
+ ...(position === 'end'
+ ? {flexDirection: 'column', marginTop: SPACING_MAP[rowGap]}
+ : {flexDirection: 'column-reverse', marginBottom: SPACING_MAP[rowGap]}),
+
+ // Regular and wide viewports
[`@media screen and (min-width: ${theme.breakpoints[1]})`]: {
width: 'auto',
- [responsivePosition === 'end' ? 'marginLeft' : 'marginRight']: SPACING_MAP[columnGap],
- marginY: `0 !important`,
- flexDirection: responsivePosition === 'end' ? 'row' : 'row-reverse',
- order: panePositions[responsivePosition]
+ marginY: '0 !important',
+ ...(sticky
+ ? {
+ position: 'sticky',
+ top: 0,
+ overflow: 'hidden',
+ maxHeight: 'var(--sticky-pane-height)'
+ }
+ : {}),
+ ...(position === 'end'
+ ? {flexDirection: 'row', marginLeft: SPACING_MAP[columnGap]}
+ : {flexDirection: 'row-reverse', marginRight: SPACING_MAP[columnGap]})
}
},
sx
@@ -389,14 +456,14 @@ const Pane: React.FC> = ({
{/* Show a horizontal divider when viewport is narrow. Otherwise, show a vertical divider. */}
- {children}
+ {children}
)
}
diff --git a/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap
index 5f9e4c680bb..99f325b6460 100644
--- a/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap
+++ b/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap
@@ -19,6 +19,13 @@ exports[`PageLayout renders condensed layout 1`] = `
}
.c5 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-direction: column;
+ -ms-flex-direction: column;
+ flex-direction: column;
-webkit-order: 2;
-ms-flex-order: 2;
order: 2;
@@ -40,12 +47,17 @@ exports[`PageLayout renders condensed layout 1`] = `
max-width: 100%;
margin-left: auto;
margin-right: auto;
+ -webkit-box-flex: 1;
+ -webkit-flex-grow: 1;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
padding: 0;
}
.c10 {
width: 100%;
padding: 0;
+ overflow: auto;
}
.c0 {
@@ -68,19 +80,19 @@ exports[`PageLayout renders condensed layout 1`] = `
}
.c7 {
- -webkit-order: 3;
- -ms-flex-order: 3;
- order: 3;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
- -webkit-flex-direction: column;
- -ms-flex-direction: column;
- flex-direction: column;
+ -webkit-order: 3;
+ -ms-flex-order: 3;
+ order: 3;
width: 100%;
margin-left: 0;
margin-right: 0;
+ -webkit-flex-direction: column;
+ -ms-flex-direction: column;
+ flex-direction: column;
margin-top: 16px;
}
@@ -127,15 +139,12 @@ exports[`PageLayout renders condensed layout 1`] = `
@media screen and (min-width:768px) {
.c7 {
width: auto;
- margin-left: 16px;
margin-top: 0 !important;
margin-bottom: 0 !important;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
- -webkit-order: 3;
- -ms-flex-order: 3;
- order: 3;
+ margin-left: 16px;
}
}
@@ -149,6 +158,7 @@ exports[`PageLayout renders condensed layout 1`] = `
+
Content
+