55 * Handles both short sidebars (smaller than viewport) and tall sidebars
66 * (taller than viewport) with different sticking strategies:
77 *
8- * SHORT SIDEBAR: Pins the sidebar's top edge below the scroll viewport's
9- * top edge (with padding) as the user scrolls down. Returns to natural
10- * position when the container scrolls back into view.
8+ * SHORT SIDEBAR: Pins the sidebar's top edge below the fixed header
9+ * (with padding) as the user scrolls down. Returns to natural position
10+ * when the container scrolls back into view.
1111 *
1212 * TALL SIDEBAR: When scrolling down, allows the sidebar to scroll naturally
1313 * until its bottom edge reaches the viewport bottom (with padding), then
1414 * pins it there. When scrolling up, releases and scrolls naturally until
15- * the top edge reaches the viewport top (with padding), then pins at top.
15+ * the top edge reaches the header bottom (with padding), then pins at top.
1616 *
17- * The scroll container is always `#scroll-viewport`, which sits below the
18- * header/progress bar in the flex layout. Its BCR top naturally accounts
19- * for all fixed chrome above content .
17+ * Measures the actual `.header-fixed` and `[data-progress-bar]` elements
18+ * each frame so the reference point always matches the visual chrome,
19+ * regardless of header collapse animations or transition timing .
2020 */
2121import { handleScriptError } from '@components/scripts/errors/handler'
22+ import {
23+ getHeaderFixedElement ,
24+ getProgressBarElement ,
25+ } from '@components/scripts/store/selectors'
2226
2327export interface StickySidebarOptions {
24- /** Extra space below the scroll viewport's visible top edge (px) . Default: 16 */
28+ /** Extra space below the fixed chrome (header + progress bar) in px . Default: 16 */
2529 topPadding ?: number
26- /** Extra space above the scroll viewport's visible bottom edge (px) . Default: 16 */
30+ /** Extra space above the viewport bottom edge in px . Default: 16 */
2731 bottomPadding ?: number
2832 /** Minimum window width to enable sticky behavior (px). Default: 1024 (lg breakpoint) */
2933 minWidth ?: number
3034}
3135
36+ /**
37+ * Compute the visual bottom of all fixed chrome above content.
38+ * Returns the maximum of header-fixed and progress-bar bottoms.
39+ */
40+ function measureChromeBottom ( ) : number {
41+ const headerEl = getHeaderFixedElement ( )
42+ const progressEl = getProgressBarElement ( )
43+ return Math . max (
44+ headerEl ? headerEl . getBoundingClientRect ( ) . bottom : 0 ,
45+ progressEl ? progressEl . getBoundingClientRect ( ) . bottom : 0 ,
46+ )
47+ }
48+
3249/**
3350 * Initialize sticky sidebar behavior on a sidebar element within a container.
3451 *
@@ -54,8 +71,8 @@ export function initStickySidebar(
5471 }
5572
5673 let currentTranslateY = 0
74+ let prevScrollTop = scrollContainer . scrollTop
5775 let rafId : number | null = null
58- // Capture scrollContainer in a const to satisfy strict null checks inside closures
5976 const scroller = scrollContainer
6077
6178 /**
@@ -70,47 +87,61 @@ export function initStickySidebar(
7087 sidebar . style . transform = ''
7188 currentTranslateY = 0
7289 }
90+ prevScrollTop = scroller . scrollTop
7391 return
7492 }
7593
94+ const scrollTop = scroller . scrollTop
95+ const scrollDelta = scrollTop - prevScrollTop
96+ prevScrollTop = scrollTop
97+
7698 const sidebarRect = sidebar . getBoundingClientRect ( )
7799 const sidebarHeight = sidebarRect . height
78100 if ( sidebarHeight === 0 ) return
79101
80102 const containerRect = container . getBoundingClientRect ( )
81- const viewportRect = scroller . getBoundingClientRect ( )
82103
83- // Sidebar's natural top position (without current transform applied)
104+ // Visible bounds: measured from actual fixed chrome, not scroll viewport BCR
105+ const visibleTop = measureChromeBottom ( ) + topPadding
106+ const visibleBottom = window . innerHeight - bottomPadding
107+ const availableHeight = visibleBottom - visibleTop
108+
109+ // Sidebar's natural top position (where it would be with translateY = 0)
84110 const naturalTop = sidebarRect . top - currentTranslateY
85111
86112 // Maximum translateY: sidebar bottom must not exceed container bottom
87113 const maxTranslateY = Math . max ( 0 , containerRect . bottom - ( naturalTop + sidebarHeight ) )
88114
89- // Visible bounds within the scroll viewport
90- const visibleTop = viewportRect . top + topPadding
91- const visibleBottom = viewportRect . bottom - bottomPadding
92- const availableHeight = visibleBottom - visibleTop
93-
94115 let newTranslateY : number
95116
96117 if ( sidebarHeight <= availableHeight ) {
97118 // SHORT SIDEBAR: pin top edge at the visible top boundary
98119 newTranslateY = Math . max ( 0 , visibleTop - naturalTop )
99120 } else {
100- // TALL SIDEBAR: direction-aware pinning.
101- // Top pin: sidebar top aligns with visibleTop
102- const topPin = visibleTop - naturalTop
103- // Bottom pin: sidebar bottom aligns with visibleBottom
104- const bottomPin = visibleBottom - sidebarHeight - naturalTop
105-
106- // When the sidebar is taller than available space, topPin > bottomPin.
107- // Clamping currentTranslateY between them gives us direction-aware
108- // behavior: scrolling down hits the bottom pin, scrolling up hits the
109- // top pin, and in between the sidebar scrolls naturally.
110- newTranslateY = Math . max ( bottomPin , Math . min ( currentTranslateY , topPin ) )
121+ // TALL SIDEBAR: direction-aware pinning
122+ const actualTop = naturalTop + currentTranslateY
123+ const actualBottom = actualTop + sidebarHeight
124+
125+ if ( scrollDelta > 0 ) {
126+ // Scrolling DOWN: pin bottom at visibleBottom when it would go above
127+ if ( actualBottom < visibleBottom ) {
128+ newTranslateY = visibleBottom - sidebarHeight - naturalTop
129+ } else {
130+ newTranslateY = currentTranslateY
131+ }
132+ } else if ( scrollDelta < 0 ) {
133+ // Scrolling UP: pin top at visibleTop when it would go below
134+ if ( actualTop > visibleTop ) {
135+ newTranslateY = visibleTop - naturalTop
136+ } else {
137+ newTranslateY = currentTranslateY
138+ }
139+ } else {
140+ newTranslateY = currentTranslateY
141+ }
111142 }
112143
113- // Global clamp : never go above natural position or below container bottom
144+ // Clamp : never go above natural position or below container bottom
114145 newTranslateY = Math . max ( 0 , Math . min ( newTranslateY , maxTranslateY ) )
115146
116147 // Apply only when value changes meaningfully (avoid sub-pixel jitter)
0 commit comments