Skip to content

Commit 995d49d

Browse files
committed
feat: add useStickyEvent hook
1 parent 5f36982 commit 995d49d

File tree

6 files changed

+143
-0
lines changed

6 files changed

+143
-0
lines changed

src/Shared/Hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export * from './UsePrompt'
1818
export * from './useGetResourceKindsOptions'
1919
export * from './UseDownload'
2020
export * from './useForm'
21+
export * from './useStickyEvent'
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const FALLBACK_SENTINEL_HEIGHT = '1px'
2+
export const OBSERVER_THRESHOLD = 1
3+
export const OBSERVER_ROOT_MARGIN = '0px'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as useStickyEvent } from './useStickyEvent'
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.sticky-container__sentinel {
2+
position: absolute;
3+
bottom: 100%;
4+
left: 0;
5+
right: 0;
6+
flex-shrink: 0;
7+
visibility: hidden;
8+
z-index: -1;
9+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Dispatch, SetStateAction, MutableRefObject } from 'react'
2+
3+
export type UseStickyEventProps<T extends HTMLElement = HTMLDivElement> = {
4+
/**
5+
* Callback function that is called when the sticky element is 'stuck' or 'unstuck'
6+
*/
7+
callback: (isStuck: boolean) => void | Dispatch<SetStateAction<boolean>>
8+
/**
9+
* Unique identifier used to create the id of the sentinel element
10+
*
11+
* A sentinel element is used to determine when the sticky element is 'stuck'
12+
* It is dynamically created and appended to the DOM
13+
*/
14+
identifier: string
15+
/**
16+
* Indicates whether the sticky element is conditionally rendered.
17+
* - Set to true if the sticky element is mounted.
18+
* - Set to false if the sticky element is not mounted.
19+
* - If the sticky element is always rendered, this flag can be ignored.
20+
*/
21+
isStickyElementMounted?: boolean
22+
} & (
23+
| {
24+
/**
25+
* Reference to the scroll container element that contains the sticky element
26+
*
27+
* Either the reference can be passed or its class name
28+
*/
29+
containerRef: MutableRefObject<T>
30+
containerClassName?: never
31+
}
32+
| { containerClassName: string; containerRef?: never }
33+
)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useEffect, useRef } from 'react'
2+
import { isNullOrUndefined } from '@Shared/Helpers'
3+
import { noop } from '@Common/Helper'
4+
import { UseStickyEventProps } from './types'
5+
import { FALLBACK_SENTINEL_HEIGHT, OBSERVER_ROOT_MARGIN, OBSERVER_THRESHOLD } from './constants'
6+
7+
import './styles.scss'
8+
9+
/**
10+
* Please read
11+
* https://developer.chrome.com/docs/css-ui/sticky-headers
12+
* as a reference for the implementation
13+
*/
14+
const useStickyEvent = <T extends HTMLElement = HTMLDivElement>({
15+
callback,
16+
containerClassName,
17+
containerRef,
18+
isStickyElementMounted,
19+
identifier,
20+
}: UseStickyEventProps<T>) => {
21+
const stickyElementRef = useRef<T>(null)
22+
23+
useEffect(
24+
() => {
25+
if (!stickyElementRef.current || (!isNullOrUndefined(isStickyElementMounted) && !isStickyElementMounted)) {
26+
return noop
27+
}
28+
29+
const stickyElementParent = containerRef
30+
? containerRef.current
31+
: Array.from(document.getElementsByClassName(containerClassName)).find((element) =>
32+
element.contains(stickyElementRef.current),
33+
)
34+
35+
if (!stickyElementParent) {
36+
return noop
37+
}
38+
39+
let previousStuckState: boolean
40+
41+
const intersectionObserver = new IntersectionObserver(
42+
(entries) => {
43+
entries.forEach((entry) => {
44+
const isStuck = !entry.isIntersecting
45+
if (isNullOrUndefined(previousStuckState) || isStuck !== previousStuckState) {
46+
callback(isStuck)
47+
previousStuckState = isStuck
48+
}
49+
})
50+
},
51+
{ root: stickyElementParent, threshold: OBSERVER_THRESHOLD, rootMargin: OBSERVER_ROOT_MARGIN },
52+
)
53+
54+
const sentinelId = `${identifier}__sentinel`
55+
56+
let sentinelElement = document.getElementById(sentinelId)
57+
58+
if (!sentinelElement) {
59+
sentinelElement = document.createElement('div')
60+
sentinelElement.id = sentinelId
61+
sentinelElement.classList.add('sticky-container__sentinel')
62+
}
63+
64+
// The sentinel element's height must exceed the sticky element's top CSS value.
65+
// This guarantees that when the sticky element sticks to the container's edge,
66+
// the sentinel element will extend beyond the scroll container.
67+
sentinelElement.style.height =
68+
window.getComputedStyle(stickyElementRef.current).top?.replace(/[0-9]+/g, (match) => {
69+
const nMatch = Number(match)
70+
if (Number.isNaN(nMatch)) {
71+
return FALLBACK_SENTINEL_HEIGHT
72+
}
73+
return `${nMatch + 1}`
74+
}) ?? FALLBACK_SENTINEL_HEIGHT
75+
76+
stickyElementRef.current.appendChild(sentinelElement)
77+
78+
intersectionObserver.observe(sentinelElement)
79+
80+
return () => {
81+
intersectionObserver.disconnect()
82+
}
83+
},
84+
// NOTE: The isStickyElementMounted dependency ensures that this effect
85+
// runs not only on mount/unmount but also when the isStickyElementMounted value changes.
86+
// This is important because the sticky element might not be present during
87+
// the initial render and could be added later based on certain conditions.
88+
// By using isStickyElementMounted as a dependency, we make sure that the effect
89+
// re-runs when the element is rendered, allowing the stickyElementRef to be populated.
90+
isNullOrUndefined(isStickyElementMounted) ? [] : [isStickyElementMounted],
91+
)
92+
93+
return { stickyElementRef }
94+
}
95+
96+
export default useStickyEvent

0 commit comments

Comments
 (0)