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 (