Skip to content

Commit b4613c5

Browse files
committed
Further work on sticky header functionality
1 parent 85780ab commit b4613c5

13 files changed

Lines changed: 417 additions & 122 deletions

File tree

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

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { useRouter } from 'next/router'
3131
import { Footer } from './Footer'
3232
import { LayoutQuery } from './Layout.gql'
3333
import { Logo } from './Logo'
34+
import { StickyBox } from '@graphcommerce/framer-utils'
3435
import { productListRenderer } from '../ProductListItems/productListRenderer'
3536

3637
export type LayoutNavigationProps = LayoutQuery &
@@ -107,22 +108,43 @@ export function LayoutNavigation(props: LayoutNavigationProps) {
107108

108109
<LayoutDefault
109110
{...uiProps}
110-
stickyHeader={router.asPath.split('?')[0] !== '/'}
111111
sx={(theme) => ({
112112
[theme.breakpoints.up('md')]: {
113-
'& .LayoutDefault-header.stickyHeader': {
113+
'& .sticky': {
114114
bgcolor: 'background.default',
115115
boxShadow: 1,
116116
},
117117
},
118118
})}
119+
// stickyHeader={router.asPath.split('?')[0] !== '/'}
120+
stickyAfterHeader
121+
// stickyBeforeHeader
119122
beforeHeader={
120-
<Container sx={{ py: { xs: 0, md: 1 }, position: 'relative', textWrap: 'balance' }}>
123+
<Container
124+
sx={{
125+
py: { xs: 0, md: 1 },
126+
position: 'relative',
127+
boxShadow: 1,
128+
textAlign: { xs: 'center', md: 'left' },
129+
}}
130+
>
121131
You are looking at the{' '}
122132
<Link color='inherit' underline='always' href='https://graphcommerce.org'>
123133
GraphCommerce
124134
</Link>{' '}
125-
demo environment
135+
demo
136+
</Container>
137+
}
138+
afterHeader={
139+
<Container
140+
sx={{
141+
py: { xs: 0, md: 1 },
142+
position: 'relative',
143+
boxShadow: 1,
144+
textAlign: { xs: 'center', md: 'left' },
145+
}}
146+
>
147+
This is a demo store, no actual products are being shipped.
126148
</Container>
127149
}
128150
header={
@@ -169,17 +191,16 @@ export function LayoutNavigation(props: LayoutNavigationProps) {
169191
</Fab>
170192
<WishlistFab icon={<IconSvg src={iconHeart} size='large' />} />
171193
<CustomerFab guestHref='/account/signin' authHref='/account' />
172-
{/* The placeholder exists because the CartFab is sticky but we want to reserve the space for the <CartFab /> */}
173-
{cartEnabled && <PlaceholderFab />}
194+
<PlaceholderFab />
174195
</DesktopNavActions>
175196

176197
<MobileTopRight>
177198
<SearchFab size='responsiveMedium' />
178199
</MobileTopRight>
179200
</>
180201
}
181-
footer={<Footer footer={footer} />}
182202
cartFab={<CartFab />}
203+
footer={<Footer footer={footer} />}
183204
menuFab={<NavigationFab onClick={() => selection.set([])} />}
184205
>
185206
{children}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ export function LayoutNavigation(props: LayoutNavigationProps) {
107107

108108
<LayoutDefault
109109
{...uiProps}
110-
noSticky={router.asPath.split('?')[0] === '/'}
111110
header={
112111
<>
113112
<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.4-canary.2",
1919
"@graphcommerce/prettier-config-pwa": "^9.0.4-canary.2",
2020
"@graphcommerce/typescript-config-pwa": "^9.0.4-canary.2",
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"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { px } from 'framer-motion'
2+
3+
export const numberToPx = (value: number) => px.transform(Math.round(value * 100) / 100)

packages/magento-category/components/CategoryChildren/CategoryChildren.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ export function CategoryChildren(props: CategoryChildrenProps) {
3232
)
3333

3434
const hasNavigatableChildren = childItems.some((cat) => !cat.active)
35+
3536
if (!hasNavigatableChildren) return null
3637

3738
return (
3839
<ScrollerProvider scrollSnapAlign='none'>
3940
<Box
4041
className={classes.container}
4142
sx={[
42-
{ display: 'flex', width: '100%', overflow: 'hidden' },
43+
{ display: 'grid', width: '100%', overflow: 'hidden' },
4344
...(Array.isArray(sx) ? sx : [sx]),
4445
]}
4546
>

0 commit comments

Comments
 (0)