@devzwo/ngx-magic-scroll 0.0.1-rc.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,23 +45,24 @@ Register defaults in `app.config.ts` or a component's providers:
45
45
 
46
46
  ```ts
47
47
  provideMagicScroll({
48
- anchorPrefix: 'project-',
48
+ anchorPrefixes: ['project-', 'milestone-'],
49
+ anchorSelection: 'all',
49
50
  headerSelector: '.app-header',
50
51
  headerOffset: 16,
51
52
  behavior: { restoration: 'instant', interaction: 'smooth' },
52
53
  });
53
54
  ```
54
55
 
55
- The header's height is measured and observed automatically. `headerOffset` is an additional gap below it, or the full offset without a header. Defaults are an empty prefix and header selector, zero offset, a 500 ms scroll-spy debounce, instant restoration and smooth interaction.
56
+ The header's height is measured and observed automatically. `headerOffset` is an additional gap below it, or the full offset without a header. Defaults are a single empty prefix in a list (`['']`, matching all descendant IDs), an empty header selector, zero offset, a 500 ms scroll-spy debounce, instant restoration and smooth interaction.
56
57
 
57
- Child providers inherit unspecified settings. Override an individual region with `[scrollOptions]="options"`; unspecified fields still inherit. Explicit `0`, `false` and `anchorPrefix: ''` override inherited values.
58
+ Child providers inherit unspecified settings. Override an individual region with `[scrollOptions]="options"`; unspecified fields still inherit. Explicit `0`, `false`, `anchorPrefixes: ['']` (matches all descendant IDs), and `anchorPrefixes: []` (matches no prefix) override inherited values.
58
59
 
59
60
  ```html
60
61
  <div
61
62
  magicScroll
62
63
  #scroll="magicScroll"
63
64
  [scrollSource]="projects"
64
- [scrollOptions]="{ headerOffset: 24 }"
65
+ [scrollOptions]="{ headerOffset: 24, anchorSelection: 'explicit' }"
65
66
  >
66
67
  ...
67
68
  </div>
@@ -72,9 +73,14 @@ Child providers inherit unspecified settings. Override an individual region with
72
73
 
73
74
  Navigation, reload and the first successful asynchronous load use `behavior.restoration`. Later data changes and resize/header corrections adjust instantly to stabilize the layout. `restoreOnDataChange: false` and `preserveOnResize: false` opt out of data corrections and resize/header corrections respectively. Visibility rules are: `none`, `top`, `full` and `always` (at least one edge visible).
74
75
 
75
- ## Optional explicit anchors
76
+ ## Anchor selection and explicit anchors
76
77
 
77
- No marker is needed for normal IDs. An explicit anchor participates even outside the configured prefix:
78
+ By default (`anchorSelection: 'all'`), all descendant IDs participate unless constrained by `anchorPrefixes`.
79
+
80
+ You can also configure strict explicit selection or multiple prefixes:
81
+
82
+ - **Multiple prefixes**: Pass `anchorPrefixes: ['item-', 'card-']` to include elements starting with any configured prefix.
83
+ - **Strict explicit mode**: Set `anchorSelection: 'explicit'` to ignore all prefixes and admit only elements bearing the `[scrollAnchor]` or `scrollAnchor` marker. Generated component IDs (such as Angular Material input and form-field IDs) are completely excluded.
78
84
 
79
85
  ```html
80
86
  <section id="overview" scrollAnchor>...</section>
@@ -27,6 +27,8 @@ import * as i0 from "@angular/core";
27
27
  import { DestroyRef, Directive, ElementRef, Injectable, InjectionToken, afterNextRender, afterRenderEffect, computed, effect, inject, input, isSignal, linkedSignal, signal, untracked } from "@angular/core";
28
28
  import { ActivatedRoute, Router } from "@angular/router";
29
29
  import { toSignal } from "@angular/core/rxjs-interop";
30
+ const MAGIC_SCROLL_ORIGIN_KEY = "__ngx_magic_scroll_origin";
31
+ const MAGIC_SCROLL_ORIGIN_SCROLL_SPY = "scroll-spy";
30
32
  function linkedRouteFragment() {
31
33
  const route = inject(ActivatedRoute);
32
34
  const router = inject(Router);
@@ -34,13 +36,20 @@ function linkedRouteFragment() {
34
36
  const actualFragment = linkedSignal(() => fragment(), ...ngDevMode ? [{ debugName: "actualFragment" }] : /* istanbul ignore next */ []);
35
37
  effect(() => {
36
38
  const newFragment = actualFragment();
37
- if (route.snapshot.fragment !== newFragment) {
39
+ if (fragment() !== newFragment) {
38
40
  const tree = router.createUrlTree([], {
39
41
  relativeTo: route,
40
42
  fragment: newFragment ?? void 0,
41
43
  queryParamsHandling: "preserve"
42
44
  });
43
- router.navigateByUrl(tree, { replaceUrl: true }).catch((error) => {
45
+ router.navigateByUrl(tree, {
46
+ replaceUrl: true,
47
+ state: {
48
+ ...history.state,
49
+ [MAGIC_SCROLL_ORIGIN_KEY]: MAGIC_SCROLL_ORIGIN_SCROLL_SPY
50
+ },
51
+ info: { [MAGIC_SCROLL_ORIGIN_KEY]: MAGIC_SCROLL_ORIGIN_SCROLL_SPY }
52
+ }).catch((error) => {
44
53
  console.log(`Cant set fragment ${newFragment}`, error);
45
54
  });
46
55
  }
@@ -130,8 +139,13 @@ var ScrollService = class ScrollService {
130
139
  const isTopInView = rect.top >= containerTop + topOffset - 1 && rect.top < containerTop + viewportHeight;
131
140
  const isBottomInView = rect.bottom <= containerTop + viewportHeight && rect.bottom > containerTop;
132
141
  if (ignoreWhenInView == "top" && isTopInView || ignoreWhenInView == "full" && isTopInView && isBottomInView || ignoreWhenInView == "always" && (isTopInView || isBottomInView)) return false;
142
+ const displacement = options?.displacement ?? 0;
143
+ const delta = rect.top - containerTop - topOffset - displacement;
144
+ const maxScrollTop = scrollable.scrollHeight > 0 && viewportHeight > 0 ? Math.max(0, scrollable.scrollHeight - viewportHeight) : Infinity;
145
+ const targetScrollTop = Math.min(Math.max(scrollable.scrollTop + delta, 0), maxScrollTop);
146
+ if (Math.abs(targetScrollTop - scrollable.scrollTop) < 1) return true;
133
147
  scrollable.scrollTo({
134
- top: scrollable.scrollTop + rect.top - containerTop - topOffset,
148
+ top: targetScrollTop,
135
149
  behavior
136
150
  });
137
151
  return true;
@@ -163,7 +177,8 @@ i0.ɵɵngDeclareClassMetadata({
163
177
  }]
164
178
  });
165
179
  const defaults = {
166
- anchorPrefix: "",
180
+ anchorPrefixes: [""],
181
+ anchorSelection: "all",
167
182
  headerSelector: "",
168
183
  headerOffset: 0,
169
184
  bottomOffset: 0,
@@ -175,11 +190,13 @@ const defaults = {
175
190
  debounceTime: 500,
176
191
  ignoreWhenInView: "none",
177
192
  restoreOnDataChange: true,
178
- preserveOnResize: true
193
+ preserveOnResize: true,
194
+ preserveOnLayoutChange: false
179
195
  };
180
196
  function resolveMagicScrollOptions(parent, options) {
181
197
  return {
182
- anchorPrefix: options.anchorPrefix ?? parent.anchorPrefix,
198
+ anchorPrefixes: options.anchorPrefixes ?? parent.anchorPrefixes,
199
+ anchorSelection: options.anchorSelection ?? parent.anchorSelection,
183
200
  headerSelector: options.headerSelector ?? parent.headerSelector,
184
201
  headerOffset: options.headerOffset ?? parent.headerOffset,
185
202
  bottomOffset: options.bottomOffset ?? parent.bottomOffset,
@@ -191,7 +208,8 @@ function resolveMagicScrollOptions(parent, options) {
191
208
  debounceTime: options.debounceTime ?? parent.debounceTime,
192
209
  ignoreWhenInView: options.ignoreWhenInView ?? parent.ignoreWhenInView,
193
210
  restoreOnDataChange: options.restoreOnDataChange ?? parent.restoreOnDataChange,
194
- preserveOnResize: options.preserveOnResize ?? parent.preserveOnResize
211
+ preserveOnResize: options.preserveOnResize ?? parent.preserveOnResize,
212
+ preserveOnLayoutChange: options.preserveOnLayoutChange ?? parent.preserveOnLayoutChange
195
213
  };
196
214
  }
197
215
  const MAGIC_SCROLL_OPTIONS = new InjectionToken("Magic scroll defaults", {
@@ -244,6 +262,15 @@ const SCROLL_KEYS = /* @__PURE__ */ new Set([
244
262
  "Spacebar",
245
263
  "Space"
246
264
  ]);
265
+ function isNaturallyFocusable(el) {
266
+ if (el.hasAttribute("tabindex")) return true;
267
+ const tag = el.tagName.toLowerCase();
268
+ if (tag === "button" || tag === "select" || tag === "textarea") return !el.disabled;
269
+ if (tag === "input") return el.type !== "hidden" && !el.disabled;
270
+ if (tag === "a" || tag === "area") return el.hasAttribute("href");
271
+ if (tag === "iframe") return true;
272
+ return false;
273
+ }
247
274
  function isEditableTarget(target) {
248
275
  if (!target || !(target instanceof Element)) return false;
249
276
  const tagName = target.tagName.toLowerCase();
@@ -263,6 +290,7 @@ var MagicScrollDirective = class MagicScrollDirective {
263
290
  host = inject(ElementRef).nativeElement;
264
291
  service = inject(ScrollService);
265
292
  nearest = inject(NearestAnchorProvider);
293
+ router = inject(Router, { optional: true });
266
294
  fragment = linkedRouteFragment();
267
295
  initialAnchor = inject(ActivatedRoute).snapshot.fragment;
268
296
  measuredHeaderHeight = signal(0, ...ngDevMode ? [{ debugName: "measuredHeaderHeight" }] : /* istanbul ignore next */ []);
@@ -273,8 +301,25 @@ var MagicScrollDirective = class MagicScrollDirective {
273
301
  scrollRevision = signal(0, ...ngDevMode ? [{ debugName: "scrollRevision" }] : /* istanbul ignore next */ []);
274
302
  initialized = false;
275
303
  userInterrupted = false;
304
+ savedDisplacement = 0;
305
+ pendingDestination = this.initialAnchor ?? null;
306
+ lastProcessedFragment = void 0;
276
307
  target;
277
308
  timer;
309
+ retryObserver;
310
+ retryTimeoutTimer;
311
+ layoutCorrectionFrameId;
312
+ temporaryTabindexCleanups = /* @__PURE__ */ new Set();
313
+ pendingFocusTarget;
314
+ scrollEndHandler;
315
+ scrollEndTarget;
316
+ focusTimeoutTimer;
317
+ focusInitiator;
318
+ isSmoothNavigating = false;
319
+ pendingLayoutPreservation = false;
320
+ smoothNavEndHandler;
321
+ smoothNavTarget;
322
+ smoothNavTimeoutTimer;
278
323
  constructor() {
279
324
  const destroyRef = inject(DestroyRef);
280
325
  afterNextRender(() => {
@@ -285,6 +330,13 @@ var MagicScrollDirective = class MagicScrollDirective {
285
330
  this.detachListeners();
286
331
  this.document.defaultView?.removeEventListener("resize", this.onResize);
287
332
  clearTimeout(this.timer);
333
+ this.cancelDestinationRetry();
334
+ this.cancelScheduledFocus();
335
+ this.cancelLayoutPreservation();
336
+ this.cancelSmoothNavigation();
337
+ for (const cleanup of this.temporaryTabindexCleanups) cleanup();
338
+ this.temporaryTabindexCleanups.clear();
339
+ this.pendingDestination = null;
288
340
  });
289
341
  afterRenderEffect((onCleanup) => {
290
342
  const selector = this.options().headerSelector;
@@ -303,21 +355,106 @@ var MagicScrollDirective = class MagicScrollDirective {
303
355
  onCleanup(() => observer.disconnect());
304
356
  }
305
357
  });
358
+ afterRenderEffect((onCleanup) => {
359
+ this.scrollSource();
360
+ if (this.options().preserveOnLayoutChange && typeof ResizeObserver !== "undefined" && typeof window !== "undefined") {
361
+ const observedDimensions = /* @__PURE__ */ new Map();
362
+ const measureAndStore = (el) => {
363
+ const rect = el.getBoundingClientRect();
364
+ const dims = {
365
+ width: rect.width,
366
+ height: rect.height
367
+ };
368
+ observedDimensions.set(el, dims);
369
+ return dims;
370
+ };
371
+ const observer = new ResizeObserver((entries) => {
372
+ let hasRealChange = false;
373
+ if (!entries || !Array.isArray(entries) || entries.length === 0) hasRealChange = true;
374
+ else for (const entry of entries) {
375
+ const target = entry.target;
376
+ const prev = observedDimensions.get(target);
377
+ const rect = target.getBoundingClientRect?.();
378
+ const width = entry.contentRect?.width ?? rect?.width ?? 0;
379
+ const height = entry.contentRect?.height ?? rect?.height ?? 0;
380
+ if (!prev) observedDimensions.set(target, {
381
+ width,
382
+ height
383
+ });
384
+ else if (prev.width !== width || prev.height !== height) {
385
+ observedDimensions.set(target, {
386
+ width,
387
+ height
388
+ });
389
+ hasRealChange = true;
390
+ }
391
+ }
392
+ if (hasRealChange) this.scheduleLayoutPreservation();
393
+ });
394
+ measureAndStore(this.host);
395
+ observer.observe(this.host);
396
+ for (const anchor of this.anchors()) {
397
+ measureAndStore(anchor);
398
+ observer.observe(anchor);
399
+ }
400
+ onCleanup(() => {
401
+ observer.disconnect();
402
+ observedDimensions.clear();
403
+ this.cancelLayoutPreservation();
404
+ });
405
+ }
406
+ });
306
407
  afterRenderEffect(() => {
307
408
  const source = this.scrollSource();
308
409
  if (!readScrollSource(source).ready) return;
309
410
  untracked(() => {
310
411
  if (!this.initialized) {
311
412
  this.initialized = true;
312
- if (!this.userInterrupted) this.execute(this.initialAnchor, { behavior: this.options().behavior.restoration });
313
- else this.liveAnchor.set(this.calculateNearestAnchor() ?? null);
314
- } else if (source !== NO_SCROLL_SOURCE && this.options().restoreOnDataChange) this.preserve("top");
413
+ if (!this.userInterrupted) {
414
+ if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) {
415
+ if (this.pendingDestination) this.scheduleDestinationRetry();
416
+ }
417
+ } else {
418
+ const nearest = this.calculateNearestAnchor() ?? null;
419
+ this.liveAnchor.set(nearest);
420
+ if (nearest) this.updateSavedDisplacement(nearest);
421
+ this.cancelDestinationRetry();
422
+ }
423
+ } else if (this.pendingDestination && !this.userInterrupted) {
424
+ if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) this.scheduleDestinationRetry();
425
+ } else if (source !== NO_SCROLL_SOURCE && this.options().restoreOnDataChange) this.preserve();
315
426
  });
316
427
  });
317
428
  effect(() => {
318
429
  const frag = this.fragment();
319
430
  untracked(() => {
431
+ const prev = this.lastProcessedFragment;
432
+ this.lastProcessedFragment = frag;
320
433
  this.liveAnchor.set(frag);
434
+ if (prev === void 0) return;
435
+ if (frag) {
436
+ if (!this.isInternalNavigation()) {
437
+ this.cancelScheduledFocus();
438
+ this.cancelLayoutPreservation();
439
+ this.cancelSmoothNavigation();
440
+ this.pendingLayoutPreservation = false;
441
+ this.userInterrupted = false;
442
+ this.savedDisplacement = 0;
443
+ this.pendingDestination = frag;
444
+ const state = readScrollSource(this.scrollSource());
445
+ if (this.initialized && state.ready) {
446
+ if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) this.scheduleDestinationRetry();
447
+ } else this.cancelDestinationRetry();
448
+ }
449
+ } else {
450
+ this.cancelScheduledFocus();
451
+ this.cancelLayoutPreservation();
452
+ this.cancelSmoothNavigation();
453
+ this.pendingLayoutPreservation = false;
454
+ this.savedDisplacement = 0;
455
+ this.pendingDestination = null;
456
+ this.cancelDestinationRetry();
457
+ }
321
458
  });
322
459
  });
323
460
  effect(() => {
@@ -326,22 +463,87 @@ var MagicScrollDirective = class MagicScrollDirective {
326
463
  if (anchor) this.fragment.set(anchor);
327
464
  });
328
465
  }
466
+ isInternalNavigation() {
467
+ const raw = typeof this.router?.getCurrentNavigation === "function" ? this.router.getCurrentNavigation() ?? this.router.lastSuccessfulNavigation : this.router?.lastSuccessfulNavigation;
468
+ const nav = typeof raw === "function" ? raw() : raw ?? null;
469
+ const stateOrigin = nav?.extras?.state?.[MAGIC_SCROLL_ORIGIN_KEY];
470
+ return nav?.extras?.info?.[MAGIC_SCROLL_ORIGIN_KEY] === "scroll-spy" || nav?.trigger !== "popstate" && stateOrigin === "scroll-spy";
471
+ }
329
472
  scrollTo(anchor, options = {}) {
473
+ this.refreshTarget();
474
+ this.cancelScheduledFocus();
475
+ const clean = anchor.replace(/^#/, "");
476
+ const targetEl = this.anchors().find((el) => el.id === clean);
477
+ const willScroll = targetEl ? this.willScroll(targetEl, options) : false;
478
+ const behavior = options.behavior ?? this.options().behavior.interaction;
479
+ if (willScroll && this.hasSmoothScrollBehavior(behavior)) this.beginSmoothNavigation();
480
+ else this.cancelSmoothNavigation();
330
481
  const executed = this.execute(anchor, {
331
482
  behavior: this.options().behavior.interaction,
332
483
  ignoreWhenInView: this.options().ignoreWhenInView,
333
- ...options
484
+ ...options,
485
+ displacement: 0
334
486
  });
335
487
  if (executed) {
336
- const clean = anchor.replace(/^#/, "");
337
488
  this.liveAnchor.set(clean);
489
+ this.savedDisplacement = 0;
490
+ this.pendingDestination = null;
491
+ this.cancelDestinationRetry();
492
+ this.cancelLayoutPreservation();
338
493
  this.fragment.set(clean);
339
494
  }
495
+ if (options.focus && targetEl) {
496
+ if (executed && willScroll && this.hasSmoothScrollBehavior(behavior)) this.scheduleFocus(targetEl);
497
+ else this.applyFocus(targetEl);
498
+ }
340
499
  return executed;
341
500
  }
501
+ tryExecutePendingDestination(behavior) {
502
+ if (!this.pendingDestination) return false;
503
+ const clean = this.pendingDestination.replace(/^#/, "");
504
+ const targetEl = this.anchors().find((el) => el.id === clean);
505
+ if ((targetEl ? this.willScroll(targetEl, { behavior }) : false) && this.hasSmoothScrollBehavior(behavior)) this.beginSmoothNavigation();
506
+ if (this.execute(this.pendingDestination, {
507
+ behavior,
508
+ displacement: 0
509
+ })) {
510
+ this.savedDisplacement = 0;
511
+ this.pendingDestination = null;
512
+ this.cancelDestinationRetry();
513
+ return true;
514
+ }
515
+ return false;
516
+ }
517
+ scheduleDestinationRetry() {
518
+ if (!this.retryObserver) {
519
+ this.retryObserver = new MutationObserver(() => {
520
+ if (readScrollSource(this.scrollSource()).ready) this.tryExecutePendingDestination(this.options().behavior.restoration);
521
+ });
522
+ this.retryObserver.observe(this.host, {
523
+ childList: true,
524
+ subtree: true,
525
+ attributes: true,
526
+ attributeFilter: ["id", "data-scroll-anchor"]
527
+ });
528
+ }
529
+ clearTimeout(this.retryTimeoutTimer);
530
+ this.retryTimeoutTimer = setTimeout(() => this.cancelDestinationRetry(), 1e3);
531
+ }
532
+ cancelDestinationRetry() {
533
+ if (this.retryObserver) {
534
+ this.retryObserver.disconnect();
535
+ this.retryObserver = void 0;
536
+ }
537
+ clearTimeout(this.retryTimeoutTimer);
538
+ this.retryTimeoutTimer = void 0;
539
+ }
342
540
  anchors() {
343
- const prefix = this.options().anchorPrefix;
344
- return Array.from(this.host.querySelectorAll("[id]")).filter((el) => !!el.id && (el.hasAttribute("data-scroll-anchor") || el.id.startsWith(prefix)) && el.closest("[magicScroll]") === this.host);
541
+ const { anchorSelection, anchorPrefixes } = this.options();
542
+ return Array.from(this.host.querySelectorAll("[id]")).filter((el) => {
543
+ if (!el.id || el.closest("[magicScroll]") !== this.host) return false;
544
+ if (anchorSelection === "explicit") return el.hasAttribute("data-scroll-anchor");
545
+ return el.hasAttribute("data-scroll-anchor") || anchorPrefixes.some((prefix) => el.id.startsWith(prefix));
546
+ });
345
547
  }
346
548
  scrollable() {
347
549
  const overflow = getComputedStyle(this.host).overflowY;
@@ -366,12 +568,63 @@ var MagicScrollDirective = class MagicScrollDirective {
366
568
  ...options
367
569
  });
368
570
  }
369
- preserve(ignoreWhenInView = "none") {
370
- if (this.initialized) this.execute(this.liveAnchor(), {
371
- behavior: "instant",
372
- ignoreWhenInView
571
+ willScroll(targetEl, options) {
572
+ const scrollable = this.scrollable() ?? this.document.scrollingElement ?? this.document.documentElement;
573
+ const isDocument = scrollable === this.document.documentElement || scrollable === this.document.body;
574
+ const containerTop = isDocument ? 0 : scrollable.getBoundingClientRect().top;
575
+ const viewportHeight = isDocument ? this.document.documentElement.clientHeight : scrollable.clientHeight;
576
+ const topOffset = options.topOffset ?? this.offset();
577
+ const delta = targetEl.getBoundingClientRect().top - containerTop - topOffset;
578
+ const maxScrollTop = scrollable.scrollHeight > 0 && viewportHeight > 0 ? Math.max(0, scrollable.scrollHeight - viewportHeight) : Infinity;
579
+ const targetScrollTop = Math.min(Math.max(scrollable.scrollTop + delta, 0), maxScrollTop);
580
+ return Math.abs(targetScrollTop - scrollable.scrollTop) >= 1;
581
+ }
582
+ hasSmoothScrollBehavior(behavior) {
583
+ if (behavior === "smooth") return true;
584
+ if (behavior !== "auto") return false;
585
+ const scrollable = this.scrollable() ?? this.document.documentElement;
586
+ return this.document.defaultView?.getComputedStyle(scrollable).scrollBehavior === "smooth";
587
+ }
588
+ preserve() {
589
+ if (this.initialized) {
590
+ if (this.isSmoothNavigating) {
591
+ this.pendingLayoutPreservation = true;
592
+ return;
593
+ }
594
+ const anchor = this.liveAnchor();
595
+ if (!anchor) return;
596
+ this.execute(anchor, {
597
+ behavior: "instant",
598
+ displacement: this.savedDisplacement
599
+ });
600
+ }
601
+ }
602
+ scheduleLayoutPreservation() {
603
+ if (!this.initialized || typeof window === "undefined") return;
604
+ if (this.isSmoothNavigating) {
605
+ this.pendingLayoutPreservation = true;
606
+ return;
607
+ }
608
+ if (this.layoutCorrectionFrameId !== void 0) return;
609
+ this.layoutCorrectionFrameId = window.requestAnimationFrame(() => {
610
+ this.layoutCorrectionFrameId = void 0;
611
+ this.preserve();
373
612
  });
374
613
  }
614
+ cancelLayoutPreservation() {
615
+ if (this.layoutCorrectionFrameId !== void 0 && typeof window !== "undefined") {
616
+ window.cancelAnimationFrame(this.layoutCorrectionFrameId);
617
+ this.layoutCorrectionFrameId = void 0;
618
+ }
619
+ }
620
+ updateSavedDisplacement(anchorId) {
621
+ const clean = anchorId.replace(/^#/, "");
622
+ const anchorEl = this.anchors().find((el) => el.id === clean);
623
+ if (!anchorEl) return;
624
+ const scrollable = this.scrollable();
625
+ const topBoundary = (!scrollable || scrollable === this.document.documentElement || scrollable === this.document.body ? 0 : scrollable.getBoundingClientRect().top) + this.offset();
626
+ this.savedDisplacement = anchorEl.getBoundingClientRect().top - topBoundary;
627
+ }
375
628
  refreshTarget() {
376
629
  const target = this.scrollable() ?? this.document;
377
630
  if (target !== this.target) {
@@ -388,8 +641,66 @@ var MagicScrollDirective = class MagicScrollDirective {
388
641
  target.addEventListener("keydown", this.onKeyDown);
389
642
  this.document.addEventListener("keydown", this.onKeyDown);
390
643
  }
644
+ applyFocus(targetEl) {
645
+ if (!this.document.defaultView || typeof targetEl.focus !== "function" || !targetEl.isConnected || !this.anchors().includes(targetEl)) return;
646
+ const addedTabindex = !isNaturallyFocusable(targetEl);
647
+ let cleanup;
648
+ if (addedTabindex) {
649
+ targetEl.setAttribute("tabindex", "-1");
650
+ const onBlur = () => cleanupFn();
651
+ const cleanupFn = () => {
652
+ targetEl.removeEventListener("blur", onBlur);
653
+ if (targetEl.getAttribute("tabindex") === "-1") targetEl.removeAttribute("tabindex");
654
+ this.temporaryTabindexCleanups.delete(cleanupFn);
655
+ };
656
+ cleanup = cleanupFn;
657
+ this.temporaryTabindexCleanups.add(cleanupFn);
658
+ targetEl.addEventListener("blur", onBlur, { once: true });
659
+ }
660
+ targetEl.focus({ preventScroll: true });
661
+ if (this.document.activeElement !== targetEl && cleanup) cleanup();
662
+ }
663
+ scheduleFocus(targetEl) {
664
+ this.refreshTarget();
665
+ this.cancelScheduledFocus();
666
+ this.pendingFocusTarget = targetEl;
667
+ this.focusInitiator = this.document.activeElement;
668
+ this.document.addEventListener("focusin", this.onFocusIn);
669
+ this.document.addEventListener("keydown", this.onFocusTransferKeyDown);
670
+ this.document.addEventListener("pointerdown", this.onFocusTransferPointerDown, true);
671
+ this.document.addEventListener("wheel", this.onFocusScrollIntent, { passive: true });
672
+ this.document.addEventListener("touchstart", this.onFocusScrollIntent, { passive: true });
673
+ this.document.addEventListener("touchmove", this.onFocusScrollIntent, { passive: true });
674
+ const target = this.target;
675
+ this.scrollEndTarget = target;
676
+ this.scrollEndHandler = () => {
677
+ this.cancelScheduledFocus();
678
+ this.applyFocus(targetEl);
679
+ };
680
+ target.addEventListener("scrollend", this.scrollEndHandler, { once: true });
681
+ this.focusTimeoutTimer = setTimeout(() => this.cancelScheduledFocus(), 5e3);
682
+ }
683
+ cancelScheduledFocus() {
684
+ if (this.scrollEndHandler && this.scrollEndTarget) {
685
+ this.scrollEndTarget.removeEventListener("scrollend", this.scrollEndHandler);
686
+ this.scrollEndHandler = void 0;
687
+ this.scrollEndTarget = void 0;
688
+ }
689
+ clearTimeout(this.focusTimeoutTimer);
690
+ this.focusTimeoutTimer = void 0;
691
+ this.document.removeEventListener("focusin", this.onFocusIn);
692
+ this.document.removeEventListener("keydown", this.onFocusTransferKeyDown);
693
+ this.document.removeEventListener("pointerdown", this.onFocusTransferPointerDown, true);
694
+ this.document.removeEventListener("wheel", this.onFocusScrollIntent);
695
+ this.document.removeEventListener("touchstart", this.onFocusScrollIntent);
696
+ this.document.removeEventListener("touchmove", this.onFocusScrollIntent);
697
+ this.focusInitiator = void 0;
698
+ this.pendingFocusTarget = void 0;
699
+ }
391
700
  detachListeners() {
392
701
  if (!this.target) return;
702
+ this.cancelScheduledFocus();
703
+ this.cancelSmoothNavigation();
393
704
  this.target.removeEventListener("scroll", this.onScroll);
394
705
  this.target.removeEventListener("wheel", this.onUserIntent);
395
706
  this.target.removeEventListener("touchstart", this.onUserIntent);
@@ -399,6 +710,12 @@ var MagicScrollDirective = class MagicScrollDirective {
399
710
  }
400
711
  onUserIntent = () => {
401
712
  this.userInterrupted = true;
713
+ this.pendingDestination = null;
714
+ this.cancelDestinationRetry();
715
+ this.cancelScheduledFocus();
716
+ this.cancelLayoutPreservation();
717
+ this.cancelSmoothNavigation();
718
+ this.pendingLayoutPreservation = false;
402
719
  };
403
720
  onKeyDown = (event) => {
404
721
  if (!(event instanceof KeyboardEvent)) return;
@@ -406,12 +723,44 @@ var MagicScrollDirective = class MagicScrollDirective {
406
723
  if (!SCROLL_KEYS.has(event.key)) return;
407
724
  const targetEl = event.target;
408
725
  const scrollable = this.scrollable();
409
- if (!targetEl || targetEl === this.document.body || targetEl === this.document.documentElement || (scrollable ? scrollable === targetEl || scrollable.contains(targetEl) : true)) this.userInterrupted = true;
726
+ if (!targetEl || targetEl === this.document.body || targetEl === this.document.documentElement || (scrollable ? scrollable === targetEl || scrollable.contains(targetEl) : true)) {
727
+ this.userInterrupted = true;
728
+ this.pendingDestination = null;
729
+ this.cancelDestinationRetry();
730
+ this.cancelScheduledFocus();
731
+ this.cancelLayoutPreservation();
732
+ this.cancelSmoothNavigation();
733
+ this.pendingLayoutPreservation = false;
734
+ }
735
+ };
736
+ onFocusScrollIntent = () => {
737
+ this.cancelScheduledFocus();
738
+ };
739
+ onFocusIn = (event) => {
740
+ if (this.pendingFocusTarget && event.target !== this.focusInitiator) this.cancelScheduledFocus();
741
+ };
742
+ onFocusTransferKeyDown = (event) => {
743
+ if (!(event instanceof KeyboardEvent)) return;
744
+ if (event.key === "Tab") {
745
+ this.cancelScheduledFocus();
746
+ return;
747
+ }
748
+ if (isEditableTarget(event.target)) return;
749
+ if (SCROLL_KEYS.has(event.key)) this.cancelScheduledFocus();
750
+ };
751
+ onFocusTransferPointerDown = (event) => {
752
+ const target = event.target;
753
+ if (this.pendingFocusTarget && target instanceof Node && target !== this.focusInitiator && !this.focusInitiator?.contains(target)) this.cancelScheduledFocus();
410
754
  };
411
755
  onScroll = () => {
412
756
  if (!this.initialized) return;
413
- const anchor = this.calculateNearestAnchor();
414
- if (anchor) this.liveAnchor.set(anchor);
757
+ if (!this.isSmoothNavigating) {
758
+ const anchor = this.calculateNearestAnchor();
759
+ if (anchor) {
760
+ this.liveAnchor.set(anchor);
761
+ this.updateSavedDisplacement(anchor);
762
+ }
763
+ }
415
764
  clearTimeout(this.timer);
416
765
  this.timer = setTimeout(() => this.scrollRevision.update((revision) => revision + 1), this.options().debounceTime);
417
766
  };
@@ -419,6 +768,42 @@ var MagicScrollDirective = class MagicScrollDirective {
419
768
  this.refreshTarget();
420
769
  if (this.options().preserveOnResize) this.preserve();
421
770
  };
771
+ beginSmoothNavigation() {
772
+ this.cancelSmoothNavigation();
773
+ this.isSmoothNavigating = true;
774
+ this.refreshTarget();
775
+ const target = this.target;
776
+ this.smoothNavTarget = target;
777
+ this.smoothNavEndHandler = () => {
778
+ this.endSmoothNavigation();
779
+ };
780
+ target.addEventListener("scrollend", this.smoothNavEndHandler, { once: true });
781
+ this.smoothNavTimeoutTimer = setTimeout(() => {
782
+ this.endSmoothNavigation();
783
+ }, 3e3);
784
+ }
785
+ endSmoothNavigation() {
786
+ this.cancelSmoothNavigation();
787
+ const anchor = this.calculateNearestAnchor();
788
+ if (anchor) {
789
+ this.liveAnchor.set(anchor);
790
+ this.updateSavedDisplacement(anchor);
791
+ }
792
+ if (this.pendingLayoutPreservation) {
793
+ this.pendingLayoutPreservation = false;
794
+ this.scheduleLayoutPreservation();
795
+ }
796
+ }
797
+ cancelSmoothNavigation() {
798
+ if (this.smoothNavEndHandler && this.smoothNavTarget) {
799
+ this.smoothNavTarget.removeEventListener("scrollend", this.smoothNavEndHandler);
800
+ this.smoothNavEndHandler = void 0;
801
+ this.smoothNavTarget = void 0;
802
+ }
803
+ clearTimeout(this.smoothNavTimeoutTimer);
804
+ this.smoothNavTimeoutTimer = void 0;
805
+ this.isSmoothNavigating = false;
806
+ }
422
807
  static ɵfac = i0.ɵɵngDeclareFactory({
423
808
  minVersion: "12.0.0",
424
809
  version: "22.2.1",