Skip to content

Commit

Permalink
Handle nested scroll containers
Browse files Browse the repository at this point in the history
Co-authored-by: Josh Black <joshblack@users.noreply.github.com>
  • Loading branch information
colebemis and joshblack committed Aug 15, 2022
1 parent b07f807 commit 52e174f
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 18 deletions.
61 changes: 53 additions & 8 deletions src/PageLayout/PageLayout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -606,13 +606,58 @@ StickyPane.argTypes = {
}
}

export default meta
export const NestedScrollContainer: Story = args => (
<Box sx={{display: 'grid', gridTemplateRows: 'auto 1fr auto', height: '100vh'}}>
<Placeholder label="Above scroll container" height={120} />
<Box sx={{overflow: 'auto'}}>
<PageLayout rowGap="none" columnGap="none" padding="none" containerWidth="full">
<PageLayout.Header padding="normal" divider="line">
<Placeholder label="Header" height={64} />
</PageLayout.Header>
<PageLayout.Content padding="normal" width="large">
<Box sx={{display: 'grid', gap: 3}}>
{Array.from({length: args.numParagraphsInContent}).map((_, i) => (
<Box key={i} as="p" sx={{margin: 0}}>
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.
</Box>
))}
</Box>
</PageLayout.Content>
<PageLayout.Pane position="start" padding="normal" divider="line" sticky>
<Box sx={{display: 'grid', gap: 3}}>
{Array.from({length: args.numParagraphsInPane}).map((_, i) => (
<Box key={i} as="p" sx={{margin: 0}}>
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.
</Box>
))}
</Box>
</PageLayout.Pane>
<PageLayout.Footer padding="normal" divider="line">
<Placeholder label="Footer" height={64} />
</PageLayout.Footer>
</PageLayout>
</Box>
<Placeholder label="Below scroll container" height={120} />
</Box>
)

// test cases
NestedScrollContainer.argTypes = {
numParagraphsInPane: {
type: 'number',
defaultValue: 10
},
numParagraphsInContent: {
type: 'number',
defaultValue: 30
}
}

// pane is long, content is short
// pane is short, content is long
// pane is long, content is long
// pane is short, content is short
// narrow viewport
// sticky disabled
export default meta
4 changes: 3 additions & 1 deletion src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ const Root: React.FC<React.PropsWithChildren<PageLayoutProps>> = ({
children,
sx = {}
}) => {
const {enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef, stickyPaneHeight} = useStickyPaneHeight()
const {rootRef, enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef, stickyPaneHeight} =
useStickyPaneHeight()
return (
<PageLayoutContext.Provider
value={{
Expand All @@ -74,6 +75,7 @@ const Root: React.FC<React.PropsWithChildren<PageLayoutProps>> = ({
}}
>
<Box
ref={rootRef}
style={{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TypeScript doesn't know about CSS custom properties
Expand Down
74 changes: 65 additions & 9 deletions src/PageLayout/useStickyPaneHeight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {useInView} from 'react-intersection-observer'
*/
// TODO: Handle sticky header
export function useStickyPaneHeight() {
const rootRef = React.useRef<HTMLDivElement>(null)

// Default the height to the viewport height
const [height, setHeight] = React.useState('100vh')

Expand All @@ -20,17 +22,32 @@ export function useStickyPaneHeight() {
// Uncomment to debug
// console.log('Recalculating pane height...')

let calculatedHeight = ''

const scrollContainer = getScrollContainer(rootRef.current)

const topRect = contentTopEntry?.target.getBoundingClientRect()
const bottomRect = contentBottomEntry?.target.getBoundingClientRect()

const topOffset = topRect ? Math.max(topRect.top, 0) : 0
const bottomOffset = bottomRect ? Math.max(window.innerHeight - bottomRect.bottom, 0) : 0
if (scrollContainer) {
const scrollRect = scrollContainer.getBoundingClientRect()

const topOffset = topRect ? Math.max(topRect.top - scrollRect.top, 0) : 0
const bottomOffset = bottomRect ? Math.max(scrollRect.bottom - bottomRect.bottom, 0) : 0

calculatedHeight = `${scrollRect.height - (topOffset + bottomOffset)}px`
} else {
const topOffset = topRect ? Math.max(topRect.top, 0) : 0
const bottomOffset = bottomRect ? Math.max(window.innerHeight - bottomRect.bottom, 0) : 0

// Safari's elastic scroll feature allows you to scroll beyond the scroll height of the page.
// We need to account for this when calculating the offset.
const overflowScroll = Math.max(window.scrollY + window.innerHeight - document.body.scrollHeight, 0)

// Safari's elastic scroll feature allows you to scroll beyond the scroll height of the page.
// We need to account for this when calculating the offset.
const overflowScroll = Math.max(window.scrollY + window.innerHeight - document.body.scrollHeight, 0)
calculatedHeight = `calc(100vh - ${topOffset + bottomOffset - overflowScroll}px)`
}

setHeight(`calc(100vh - ${topOffset + bottomOffset - overflowScroll}px)`)
setHeight(calculatedHeight)
}, [contentTopEntry, contentBottomEntry])

// We only want to add scroll and resize listeners if the pane is sticky.
Expand All @@ -39,27 +56,66 @@ export function useStickyPaneHeight() {
const [isEnabled, setIsEnabled] = React.useState(false)

React.useLayoutEffect(() => {
const scrollContainer = getScrollContainer(rootRef.current)

if (isEnabled && (contentTopInView || contentBottomInView)) {
calculateHeight()

// Start listeners if the top or the bottom edge of the content region is visible
// eslint-disable-next-line github/prefer-observers
window.addEventListener('scroll', calculateHeight)

if (scrollContainer) {
// eslint-disable-next-line github/prefer-observers
scrollContainer.addEventListener('scroll', calculateHeight)
} else {
// eslint-disable-next-line github/prefer-observers
window.addEventListener('scroll', calculateHeight)
}

// eslint-disable-next-line github/prefer-observers
window.addEventListener('resize', calculateHeight)
}

return () => {
// Stop listeners if neither the top nor the bottom edge of the content region is visible
window.removeEventListener('scroll', calculateHeight)

if (scrollContainer) {
scrollContainer.removeEventListener('scroll', calculateHeight)
} else {
window.removeEventListener('scroll', calculateHeight)
}

window.removeEventListener('resize', calculateHeight)
}
}, [isEnabled, contentTopInView, contentBottomInView, calculateHeight])

return {
rootRef,
enableStickyPane: () => setIsEnabled(true),
disableStickyPane: () => setIsEnabled(false),
contentTopRef,
contentBottomRef,
stickyPaneHeight: height
}
}

/**
* Returns the nearest scrollable parent of the element or `null` if the element
* is not contained in a scrollable element.
*/
function getScrollContainer(element: Element | null): Element | null {
if (!element || element === document.body) {
return null
}

return isScrollable(element) ? element : getScrollContainer(element.parentElement)
}

/** Returns `true` if the element is scrollable */
function isScrollable(element: Element) {
const hasScrollableContent = element.scrollHeight > element.clientHeight

const overflowYStyle = window.getComputedStyle(element).overflowY
const isOverflowHidden = overflowYStyle.indexOf('hidden') !== -1

return hasScrollableContent && !isOverflowHidden
}

0 comments on commit 52e174f

Please sign in to comment.