@@ -25,7 +25,8 @@ export type ScrollSpy = {
2525 * The `rootMargin` shrinks the observed viewport to a band near the top of the container, so a
2626 * heading counts as "in view" once it reaches that band. The active entry is the topmost heading
2727 * currently in the band; when the band is empty (scrolled between two headings), the last heading
28- * above the band stays active, so there is always exactly one active item.
28+ * above the band stays active, so there is always exactly one active item. When the container is
29+ * scrolled to the bottom, the last entry is activated, since trailing sections can't reach the band.
2930 */
3031export function createScrollSpy ( root : HTMLElement ) : ScrollSpy {
3132 const activeId = writable < string | null > ( null ) ;
@@ -35,12 +36,29 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
3536 // While locked (during a click-to-scroll), observer updates are ignored so the active item stays
3637 // pinned to the target instead of walking through every section the scroll passes.
3738 let locked = false ;
39+ // Whether the container is scrolled to its bottom. A trailing section can never reach the top
40+ // activation band (the container bottoms out first), so at the bottom we activate the last entry.
41+ let atBottom = false ;
3842 // Tracks per-id intersection state so we can pick the topmost visible heading on each change.
3943 const intersecting = new Map < string , boolean > ( ) ;
4044
45+ function isAtBottom ( ) : boolean {
46+ // Only when the container is actually scrollable and scrolled to (near) its end.
47+ return (
48+ root . scrollHeight > root . clientHeight &&
49+ root . scrollTop + root . clientHeight >= root . scrollHeight - 2
50+ ) ;
51+ }
52+
4153 function recompute ( ) {
4254 if ( locked ) return ;
4355
56+ // At the bottom, highlight the last section (it can't scroll up into the activation band).
57+ if ( atBottom && entries . length > 0 ) {
58+ activeId . set ( entries [ entries . length - 1 ] . id ) ;
59+ return ;
60+ }
61+
4462 // Prefer the topmost heading currently in the activation band.
4563 const visible = entries . filter ( ( e ) => intersecting . get ( e . id ) ) ;
4664 if ( visible . length > 0 ) {
@@ -66,6 +84,8 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
6684 entries = next ;
6785 intersecting . clear ( ) ;
6886 observer ?. disconnect ( ) ;
87+ // Content height changed, so re-check bottom state before recomputing.
88+ atBottom = isAtBottom ( ) ;
6989
7090 if ( typeof IntersectionObserver === "undefined" || next . length === 0 ) {
7191 activeId . set ( next [ 0 ] ?. id ?? null ) ;
@@ -88,6 +108,18 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
88108 recompute ( ) ;
89109 }
90110
111+ // A lightweight passive listener that only tracks entering/leaving the bottom (an O(1) check) and
112+ // recomputes on that transition. The IntersectionObserver still drives normal tracking; this does
113+ // not recompute on every scroll frame.
114+ function onScroll ( ) {
115+ const next = isAtBottom ( ) ;
116+ if ( next !== atBottom ) {
117+ atBottom = next ;
118+ recompute ( ) ;
119+ }
120+ }
121+ root . addEventListener ( "scroll" , onScroll , { passive : true } ) ;
122+
91123 return {
92124 activeId,
93125 setEntries,
@@ -100,6 +132,9 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
100132 locked = false ;
101133 recompute ( ) ;
102134 } ,
103- destroy : ( ) => observer ?. disconnect ( ) ,
135+ destroy : ( ) => {
136+ root . removeEventListener ( "scroll" , onScroll ) ;
137+ observer ?. disconnect ( ) ;
138+ } ,
104139 } ;
105140}
0 commit comments