Skip to content

Commit ec11e25

Browse files
committed
Further work on sticky header functionality
1 parent 7fc22c6 commit ec11e25

File tree

16 files changed

+424
-134
lines changed

16 files changed

+424
-134
lines changed

.changeset/polite-plums-breathe.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/pre.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,6 @@
229229
"pink-students-greet",
230230
"polite-crabs-cry",
231231
"polite-kiwis-wash",
232-
"polite-plums-breathe",
233232
"poor-masks-learn",
234233
"poor-plants-look",
235234
"poor-swans-sip",

examples/magento-graphcms/components/Layout/LayoutNavigation.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { useRouter } from 'next/router'
3030
import { Footer } from './Footer'
3131
import { LayoutQuery } from './Layout.gql'
3232
import { Logo } from './Logo'
33+
import { StickyBox } from '@graphcommerce/framer-utils'
3334

3435
export type LayoutNavigationProps = LayoutQuery &
3536
Omit<LayoutDefaultProps, 'footer' | 'header' | 'cartFab' | 'menuFab'>
@@ -118,22 +119,43 @@ export function LayoutNavigation(props: LayoutNavigationProps) {
118119

119120
<LayoutDefault
120121
{...uiProps}
121-
stickyHeader={router.asPath.split('?')[0] !== '/'}
122122
sx={(theme) => ({
123123
[theme.breakpoints.up('md')]: {
124-
'& .LayoutDefault-header.stickyHeader': {
124+
'& .sticky': {
125125
bgcolor: 'background.default',
126126
boxShadow: 1,
127127
},
128128
},
129129
})}
130+
// stickyHeader={router.asPath.split('?')[0] !== '/'}
131+
stickyAfterHeader
132+
// stickyBeforeHeader
130133
beforeHeader={
131-
<Container sx={{ py: { xs: 0, md: 1 }, position: 'relative', textWrap: 'balance' }}>
134+
<Container
135+
sx={{
136+
py: { xs: 0, md: 1 },
137+
position: 'relative',
138+
boxShadow: 1,
139+
textAlign: { xs: 'center', md: 'left' },
140+
}}
141+
>
132142
You are looking at the{' '}
133143
<Link color='inherit' underline='always' href='https://graphcommerce.org'>
134144
GraphCommerce
135145
</Link>{' '}
136-
demo environment
146+
demo
147+
</Container>
148+
}
149+
afterHeader={
150+
<Container
151+
sx={{
152+
py: { xs: 0, md: 1 },
153+
position: 'relative',
154+
boxShadow: 1,
155+
textAlign: { xs: 'center', md: 'left' },
156+
}}
157+
>
158+
This is a demo store, no actual products are being shipped.
137159
</Container>
138160
}
139161
header={
@@ -176,13 +198,12 @@ export function LayoutNavigation(props: LayoutNavigationProps) {
176198
</Fab>
177199
<WishlistFab icon={<IconSvg src={iconHeart} size='large' />} />
178200
<CustomerFab guestHref='/account/signin' authHref='/account' />
179-
{/* The placeholder exists because the CartFab is sticky but we want to reserve the space for the <CartFab /> */}
180-
{cartEnabled && <PlaceholderFab />}
201+
<PlaceholderFab />
181202
</DesktopNavActions>
182203
</>
183204
}
184-
footer={<Footer footer={footer} />}
185205
cartFab={<CartFab />}
206+
footer={<Footer footer={footer} />}
186207
menuFab={<NavigationFab onClick={() => selection.set([])} />}
187208
>
188209
{children}

examples/magento-open-source/components/Layout/LayoutNavigation.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ import {
99
DesktopNavActions,
1010
DesktopNavBar,
1111
DesktopNavItem,
12+
iconChevronDown,
13+
iconCustomerService,
14+
iconHeart,
1215
IconSvg,
1316
LayoutDefault,
1417
MenuFabSecondaryItem,
1518
NavigationFab,
1619
NavigationOverlay,
1720
NavigationProvider,
1821
PlaceholderFab,
19-
iconChevronDown,
20-
iconCustomerService,
21-
iconHeart,
2222
useMemoDeep,
2323
useNavigationSelection,
2424
} from '@graphcommerce/next-ui'
@@ -117,7 +117,6 @@ export function LayoutNavigation(props: LayoutNavigationProps) {
117117

118118
<LayoutDefault
119119
{...uiProps}
120-
noSticky={router.asPath.split('?')[0] === '/'}
121120
header={
122121
<>
123122
<Logo />
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Box, useForkRef } from '@mui/material'
2+
import type { BoxProps } from '@mui/material/Box'
3+
import clsx from 'clsx'
4+
import { px } from 'framer-motion'
5+
import React, { useRef } from 'react'
6+
import type { StickyStackConfig } from '../hooks/useStickyTop'
7+
import { useStickyTop } from '../hooks/useStickyTop'
8+
import { numberToPx } from '../utils/numberToPx'
9+
10+
export const StickyBox = React.forwardRef<
11+
HTMLDivElement,
12+
BoxProps & {
13+
stickyConfig: Omit<StickyStackConfig<HTMLDivElement>, 'ref'>
14+
}
15+
>((props, forwardedRef) => {
16+
const { sx, children, className, stickyConfig, ...rest } = props
17+
const ref = useRef<HTMLDivElement>(null)
18+
const forkedRef = useForkRef(forwardedRef, ref)
19+
const top = useStickyTop({ ref, ...stickyConfig })
20+
21+
return (
22+
<Box
23+
ref={forkedRef}
24+
style={{ '--top': px.transform(top) }}
25+
sx={[
26+
{ '&.sticky': { position: 'sticky', top: 'var(--top, 0px)' } },
27+
...(Array.isArray(sx) ? sx : [sx]),
28+
]}
29+
className={clsx(className, stickyConfig.sticky && 'sticky')}
30+
{...rest}
31+
>
32+
{children}
33+
</Box>
34+
)
35+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { px } from 'framer-motion'
2+
import { numberToPx } from '../utils/numberToPx'
3+
import type { UseMotionRectOptions } from './useMotionRect'
4+
import { useMotionRect } from './useMotionRect'
5+
import { useMotionValueValue } from './useMotionValueValue'
6+
7+
type UseMakeFullScreenReturn = {
8+
marginTop: string
9+
marginBottom: string
10+
marginLeft: string
11+
marginRight: string
12+
margin: `${string} ${string} ${string} ${string}`
13+
}
14+
15+
/** Calculate negative margin values to make an element fullscreen. */
16+
export function useMakeFullscreen<E extends HTMLElement>(
17+
ref: React.RefObject<E>,
18+
options?: UseMotionRectOptions,
19+
): UseMakeFullScreenReturn {
20+
const rect = useMotionRect(ref, options)
21+
22+
return useMotionValueValue(rect, (r) => {
23+
const { top, left, marginBottom, marginRight } = r
24+
const mt = numberToPx(top * -1)
25+
const mb = numberToPx(marginBottom * -1)
26+
const ml = numberToPx(left * -1)
27+
const mr = numberToPx(marginRight * -1)
28+
29+
return {
30+
marginTop: mt,
31+
marginBottom: mb,
32+
marginLeft: ml,
33+
marginRight: mr,
34+
margin: `${mt} ${mr} ${mb} ${ml}`,
35+
}
36+
})
37+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { useMotionValue } from 'framer-motion'
2+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
3+
4+
const emptyRect = {
5+
top: 0,
6+
bottom: 0,
7+
left: 0,
8+
right: 0,
9+
height: 0,
10+
width: 0,
11+
marginTop: 0,
12+
marginLeft: 0,
13+
marginRight: 0,
14+
marginBottom: 0,
15+
}
16+
17+
export type Rect = typeof emptyRect
18+
19+
export type UseMotionRectOptions = {
20+
pause?: boolean
21+
windowResize?: boolean
22+
resizeObserver?: boolean
23+
}
24+
25+
export function useMotionRect<E extends HTMLElement>(
26+
ref: React.RefObject<E>,
27+
options?: UseMotionRectOptions,
28+
) {
29+
const { pause = false, windowResize = true, resizeObserver = false } = options ?? {}
30+
31+
const motionRect = useMotionValue<Rect>(emptyRect)
32+
33+
useIsomorphicLayoutEffect(() => {
34+
if (!ref?.current || pause) return () => {}
35+
36+
const onResize = () => {
37+
if (ref.current) {
38+
const { bottom, right, top, left, height, width } = ref.current.getBoundingClientRect()
39+
const { clientHeight, clientWidth } = document.documentElement
40+
motionRect.set({
41+
top,
42+
bottom,
43+
left,
44+
right,
45+
height,
46+
width,
47+
marginRight: clientWidth - right,
48+
marginBottom: clientHeight - bottom,
49+
marginTop: top,
50+
marginLeft: left,
51+
})
52+
}
53+
}
54+
onResize()
55+
56+
let ro: ResizeObserver | undefined
57+
if (resizeObserver) {
58+
ro = new ResizeObserver(onResize)
59+
ro.observe(ref.current)
60+
}
61+
62+
if (windowResize) {
63+
window.addEventListener('resize', onResize)
64+
}
65+
66+
return () => {
67+
ro?.disconnect()
68+
window.removeEventListener('resize', onResize)
69+
}
70+
}, [motionRect, pause, ref, windowResize, resizeObserver])
71+
72+
return motionRect
73+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { MotionValue } from 'framer-motion'
2+
import { motionValue, useTransform } from 'framer-motion'
3+
import { numberToPx } from '../utils/numberToPx'
4+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
5+
import type { Rect } from './useMotionRect'
6+
import { useMotionRect } from './useMotionRect'
7+
import { useMotionValueValue } from './useMotionValueValue'
8+
9+
type StickyTo = string | null
10+
type Position = 'end' | 'center'
11+
12+
export type StickyStackConfig<E extends HTMLElement> = {
13+
/** Ref for the element to make sticky */
14+
ref: React.RefObject<E>
15+
/** Name for other sticky elements to provide the in the stickyTo value */
16+
name: string
17+
/** If it is the first element in the stack, set this to null. */
18+
to: StickyTo
19+
/** Whether the element should be sticky */
20+
sticky?: boolean
21+
/** How to position the element relative to the stickyTo element */
22+
position?: Position
23+
}
24+
25+
type StickyItemMeasured = StickyStackConfig<HTMLElement> & { rect: Rect }
26+
27+
/**
28+
* We're using a motionValue to store the global sticky stack.
29+
*
30+
* Any other state management solution with selectors would also work.
31+
*/
32+
const stickyContext = motionValue<Record<string, StickyItemMeasured>>({})
33+
34+
function get(stickyName: string | null) {
35+
return stickyName ? stickyContext.get()[stickyName] : undefined
36+
}
37+
38+
function has(stickyName: string) {
39+
return stickyContext.get()[stickyName] !== undefined
40+
}
41+
42+
function set<E extends HTMLElement>(options: StickyStackConfig<E>, rect: MotionValue<Rect>) {
43+
const { name, sticky = true, position = 'end' } = options
44+
const current = { ...stickyContext.get() }
45+
current[name] = { ...options, rect: rect.get(), sticky, position }
46+
stickyContext.set(current)
47+
}
48+
49+
function del<E extends HTMLElement>(options: StickyStackConfig<E>) {
50+
const current = { ...stickyContext.get() }
51+
delete current[options.name]
52+
stickyContext.set(current)
53+
}
54+
55+
function findStickTo(to: StickyTo) {
56+
const sticky = get(to)
57+
if (!sticky) return null
58+
if (sticky.sticky) return sticky
59+
return findStickTo(sticky.to)
60+
}
61+
62+
/**
63+
* An hook that allows you to create a sticky stack of elements.
64+
*
65+
* It calculates the top value of an element based on the size of elements in the stack before it.
66+
*/
67+
export function useStickyTop<E extends HTMLElement>(config: StickyStackConfig<E>) {
68+
const rect = useMotionRect(config.ref)
69+
70+
if (!has(config.name)) set(config, rect)
71+
72+
useIsomorphicLayoutEffect(() => {
73+
const onChange = () => set(config, rect)
74+
onChange()
75+
rect.on('change', onChange)
76+
77+
return () => del(config)
78+
}, [config, rect])
79+
80+
return useMotionValueValue(
81+
useTransform(() => {
82+
const getPosition = (self: StickyItemMeasured | undefined): number => {
83+
if (!self?.sticky) return 0
84+
85+
const to = findStickTo(self?.to)
86+
if (!to) return 0
87+
88+
const pos = self.position ?? 'end'
89+
switch (pos) {
90+
case 'center':
91+
return to.rect.height / 2 - self.rect.height / 2 + getPosition(to)
92+
case 'end':
93+
return (to.sticky ? to.rect.height : 0) + getPosition(to)
94+
default:
95+
throw new Error('Invalid position')
96+
}
97+
}
98+
99+
return getPosition(get(config.name))
100+
}),
101+
(v) => v,
102+
)
103+
}

packages/framer-utils/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
export * from './utils/clientSize'
1+
export * from './components/StickyBox'
22

3+
export * from './utils/clientSize'
34
export * from './utils/styled'
5+
export * from './utils/numberToPx'
46

57
export * from './hooks/useMeasureDynamicViewportSize'
68
export * from './hooks/useConstant'
79
export * from './hooks/useElementScroll'
810
export * from './hooks/useIsomorphicLayoutEffect'
911
export * from './hooks/useMotionValueValue'
1012
export * from './hooks/useMotionSelector'
13+
export * from './hooks/useMotionRect'
14+
export * from './hooks/useStickyTop'

packages/framer-utils/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"@graphcommerce/eslint-config-pwa": "^9.0.0-canary.110",
1919
"@graphcommerce/prettier-config-pwa": "^9.0.0-canary.110",
2020
"@graphcommerce/typescript-config-pwa": "^9.0.0-canary.110",
21+
"@mui/material": "^5.10.16",
22+
"clsx": "^2.1.1",
2123
"framer-motion": "^11.0.0",
2224
"react": "^18.2.0",
2325
"react-dom": "^18.2.0"

0 commit comments

Comments
 (0)