@@ -63,6 +63,12 @@ export class ScrollSpyController implements ReactiveController {
6363 /** Ignore intersections? */
6464 #force = false ;
6565
66+ /** AbortController to cancel previous force-release listeners */
67+ #forceAbort?: AbortController ;
68+
69+ /** Timeout handle for force-release safety valve */
70+ #forceTimeout?: ReturnType < typeof setTimeout > ;
71+
6672 /** Has the intersection observer found an element? */
6773 #intersected = false ;
6874
@@ -144,10 +150,23 @@ export class ScrollSpyController implements ReactiveController {
144150 hostDisconnected ( ) : void {
145151 ScrollSpyController . #instances. delete ( this ) ;
146152 this . #io?. disconnect ( ) ;
153+ this . #releaseForce( ) ;
147154 }
148155
149156 #initializing = true ;
150157
158+ /** Cancel force mode and clean up associated listeners */
159+ #releaseForce( ) {
160+ if ( ! this . #force) {
161+ return ;
162+ }
163+ this . #force = false ;
164+ this . #forceAbort?. abort ( ) ;
165+ this . #forceAbort = undefined ;
166+ clearTimeout ( this . #forceTimeout) ;
167+ this . #forceTimeout = undefined ;
168+ }
169+
151170 async #initIo( ) {
152171 const rootNode = this . #getRootNode( ) ;
153172 if ( rootNode instanceof Document || rootNode instanceof ShadowRoot ) {
@@ -193,25 +212,34 @@ export class ScrollSpyController implements ReactiveController {
193212
194213 async #nextIntersection( ) {
195214 this . #intersected = false ;
196- // safeguard the loop
197- setTimeout ( ( ) => this . #intersected = false , 3000 ) ;
215+ // safeguard: break the loop after 3s even if no intersection fires
216+ const timer = setTimeout ( ( ) => this . #intersected = true , 3000 ) ;
198217 while ( ! this . #intersected) {
199218 await new Promise ( requestAnimationFrame ) ;
200219 }
220+ clearTimeout ( timer ) ;
201221 }
202222
203223 async #onIo( entries : IntersectionObserverEntry [ ] ) {
204224 if ( ! this . #force) {
205- for ( const { target, boundingClientRect, intersectionRect } of entries ) {
225+ for ( const entry of entries ) {
226+ const { target, boundingClientRect } = entry ;
206227 const selector = `:is(${ this . #tagNames. join ( ',' ) } )[href="#${ target . id } "]` ;
207228 const link = this . host . querySelector ( selector ) ;
208229 if ( link ) {
209- this . #markPassed( link , boundingClientRect . top < intersectionRect . top ) ;
230+ // Mark as passed if the element's top has reached the root's top edge.
231+ // Using rootBounds (not intersectionRect) so that elements exactly AT the
232+ // viewport top are correctly considered "passed" (the current section).
233+ const rootTop = entry . rootBounds ?. top ?? 0 ;
234+ this . #markPassed( link , boundingClientRect . top <= rootTop + 2 ) ;
210235 }
211236 }
212- const link = [ ...this . #passedLinks] ;
213- const last = link . at ( - 1 ) ;
214- this . #setActive( last ?? this . #linkChildren. at ( 0 ) ) ;
237+ // Sort passed links by DOM order rather than Set insertion order
238+ const linkOrder = this . #linkChildren;
239+ const passed = [ ...this . #passedLinks]
240+ . sort ( ( a , b ) => linkOrder . indexOf ( a ) - linkOrder . indexOf ( b ) ) ;
241+ const last = passed . at ( - 1 ) ;
242+ this . #setActive( last ?? linkOrder . at ( 0 ) ) ;
215243 }
216244 this . #intersected = true ;
217245 this . #intersectingTargets. clear ( ) ;
@@ -242,16 +270,26 @@ export class ScrollSpyController implements ReactiveController {
242270 * @param link usually an `<a>`
243271 */
244272 public async setActive ( link : EventTarget | null ) : Promise < void > {
273+ // Cancel any previous programmatic scroll's force state
274+ this . #forceAbort?. abort ( ) ;
275+ clearTimeout ( this . #forceTimeout) ;
276+
245277 this . #force = true ;
246278 this . #setActive( link ) ;
279+
247280 let sawActive = false ;
248281 for ( const child of this . #linkChildren) {
249282 this . #markPassed( child , ! sawActive ) ;
250283 if ( child === link ) {
251284 sawActive = true ;
252285 }
253286 }
254- await this . #nextIntersection( ) ;
255- this . #force = false ;
287+
288+ // Force is released when the scroll completes (scrollend event),
289+ // or after a 3-second safety timeout
290+ this . #forceAbort = new AbortController ( ) ;
291+ const { signal } = this . #forceAbort;
292+ addEventListener ( 'scrollend' , ( ) => this . #releaseForce( ) , { once : true , signal } ) ;
293+ this . #forceTimeout = setTimeout ( ( ) => this . #releaseForce( ) , 3000 ) ;
256294 }
257295}
0 commit comments