@devzwo/ngx-magic-scroll 1.0.0 → 1.1.1

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.
@@ -139,8 +139,13 @@ var ScrollService = class ScrollService {
139
139
  const isTopInView = rect.top >= containerTop + topOffset - 1 && rect.top < containerTop + viewportHeight;
140
140
  const isBottomInView = rect.bottom <= containerTop + viewportHeight && rect.bottom > containerTop;
141
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;
142
147
  scrollable.scrollTo({
143
- top: scrollable.scrollTop + rect.top - containerTop - topOffset,
148
+ top: targetScrollTop,
144
149
  behavior
145
150
  });
146
151
  return true;
@@ -185,7 +190,8 @@ const defaults = {
185
190
  debounceTime: 500,
186
191
  ignoreWhenInView: "none",
187
192
  restoreOnDataChange: true,
188
- preserveOnResize: true
193
+ preserveOnResize: true,
194
+ preserveOnLayoutChange: false
189
195
  };
190
196
  function resolveMagicScrollOptions(parent, options) {
191
197
  return {
@@ -202,7 +208,8 @@ function resolveMagicScrollOptions(parent, options) {
202
208
  debounceTime: options.debounceTime ?? parent.debounceTime,
203
209
  ignoreWhenInView: options.ignoreWhenInView ?? parent.ignoreWhenInView,
204
210
  restoreOnDataChange: options.restoreOnDataChange ?? parent.restoreOnDataChange,
205
- preserveOnResize: options.preserveOnResize ?? parent.preserveOnResize
211
+ preserveOnResize: options.preserveOnResize ?? parent.preserveOnResize,
212
+ preserveOnLayoutChange: options.preserveOnLayoutChange ?? parent.preserveOnLayoutChange
206
213
  };
207
214
  }
208
215
  const MAGIC_SCROLL_OPTIONS = new InjectionToken("Magic scroll defaults", {
@@ -255,6 +262,15 @@ const SCROLL_KEYS = /* @__PURE__ */ new Set([
255
262
  "Spacebar",
256
263
  "Space"
257
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
+ }
258
274
  function isEditableTarget(target) {
259
275
  if (!target || !(target instanceof Element)) return false;
260
276
  const tagName = target.tagName.toLowerCase();
@@ -285,12 +301,25 @@ var MagicScrollDirective = class MagicScrollDirective {
285
301
  scrollRevision = signal(0, ...ngDevMode ? [{ debugName: "scrollRevision" }] : /* istanbul ignore next */ []);
286
302
  initialized = false;
287
303
  userInterrupted = false;
304
+ savedDisplacement = 0;
288
305
  pendingDestination = this.initialAnchor ?? null;
289
306
  lastProcessedFragment = void 0;
290
307
  target;
291
308
  timer;
292
309
  retryObserver;
293
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;
294
323
  constructor() {
295
324
  const destroyRef = inject(DestroyRef);
296
325
  afterNextRender(() => {
@@ -302,6 +331,11 @@ var MagicScrollDirective = class MagicScrollDirective {
302
331
  this.document.defaultView?.removeEventListener("resize", this.onResize);
303
332
  clearTimeout(this.timer);
304
333
  this.cancelDestinationRetry();
334
+ this.cancelScheduledFocus();
335
+ this.cancelLayoutPreservation();
336
+ this.cancelSmoothNavigation();
337
+ for (const cleanup of this.temporaryTabindexCleanups) cleanup();
338
+ this.temporaryTabindexCleanups.clear();
305
339
  this.pendingDestination = null;
306
340
  });
307
341
  afterRenderEffect((onCleanup) => {
@@ -321,6 +355,55 @@ var MagicScrollDirective = class MagicScrollDirective {
321
355
  onCleanup(() => observer.disconnect());
322
356
  }
323
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
+ });
324
407
  afterRenderEffect(() => {
325
408
  const source = this.scrollSource();
326
409
  if (!readScrollSource(source).ready) return;
@@ -332,15 +415,14 @@ var MagicScrollDirective = class MagicScrollDirective {
332
415
  if (this.pendingDestination) this.scheduleDestinationRetry();
333
416
  }
334
417
  } else {
335
- this.liveAnchor.set(this.calculateNearestAnchor() ?? null);
418
+ const nearest = this.calculateNearestAnchor() ?? null;
419
+ this.liveAnchor.set(nearest);
420
+ if (nearest) this.updateSavedDisplacement(nearest);
336
421
  this.cancelDestinationRetry();
337
422
  }
338
- } else if (!this.userInterrupted) {
339
- if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) {
340
- if (this.pendingDestination) this.scheduleDestinationRetry();
341
- else if (source !== NO_SCROLL_SOURCE && this.options().restoreOnDataChange) this.preserve("top");
342
- }
343
- }
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();
344
426
  });
345
427
  });
346
428
  effect(() => {
@@ -352,7 +434,12 @@ var MagicScrollDirective = class MagicScrollDirective {
352
434
  if (prev === void 0) return;
353
435
  if (frag) {
354
436
  if (!this.isInternalNavigation()) {
437
+ this.cancelScheduledFocus();
438
+ this.cancelLayoutPreservation();
439
+ this.cancelSmoothNavigation();
440
+ this.pendingLayoutPreservation = false;
355
441
  this.userInterrupted = false;
442
+ this.savedDisplacement = 0;
356
443
  this.pendingDestination = frag;
357
444
  const state = readScrollSource(this.scrollSource());
358
445
  if (this.initialized && state.ready) {
@@ -360,6 +447,11 @@ var MagicScrollDirective = class MagicScrollDirective {
360
447
  } else this.cancelDestinationRetry();
361
448
  }
362
449
  } else {
450
+ this.cancelScheduledFocus();
451
+ this.cancelLayoutPreservation();
452
+ this.cancelSmoothNavigation();
453
+ this.pendingLayoutPreservation = false;
454
+ this.savedDisplacement = 0;
363
455
  this.pendingDestination = null;
364
456
  this.cancelDestinationRetry();
365
457
  }
@@ -378,23 +470,44 @@ var MagicScrollDirective = class MagicScrollDirective {
378
470
  return nav?.extras?.info?.[MAGIC_SCROLL_ORIGIN_KEY] === "scroll-spy" || nav?.trigger !== "popstate" && stateOrigin === "scroll-spy";
379
471
  }
380
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();
381
481
  const executed = this.execute(anchor, {
382
482
  behavior: this.options().behavior.interaction,
383
483
  ignoreWhenInView: this.options().ignoreWhenInView,
384
- ...options
484
+ ...options,
485
+ displacement: 0
385
486
  });
386
487
  if (executed) {
387
- const clean = anchor.replace(/^#/, "");
388
488
  this.liveAnchor.set(clean);
489
+ this.savedDisplacement = 0;
389
490
  this.pendingDestination = null;
390
491
  this.cancelDestinationRetry();
492
+ this.cancelLayoutPreservation();
391
493
  this.fragment.set(clean);
392
494
  }
495
+ if (options.focus && targetEl) {
496
+ if (executed && willScroll && this.hasSmoothScrollBehavior(behavior)) this.scheduleFocus(targetEl);
497
+ else this.applyFocus(targetEl);
498
+ }
393
499
  return executed;
394
500
  }
395
501
  tryExecutePendingDestination(behavior) {
396
502
  if (!this.pendingDestination) return false;
397
- if (this.execute(this.pendingDestination, { behavior })) {
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;
398
511
  this.pendingDestination = null;
399
512
  this.cancelDestinationRetry();
400
513
  return true;
@@ -455,12 +568,63 @@ var MagicScrollDirective = class MagicScrollDirective {
455
568
  ...options
456
569
  });
457
570
  }
458
- preserve(ignoreWhenInView = "none") {
459
- if (this.initialized) this.execute(this.liveAnchor(), {
460
- behavior: "instant",
461
- 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();
462
612
  });
463
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
+ }
464
628
  refreshTarget() {
465
629
  const target = this.scrollable() ?? this.document;
466
630
  if (target !== this.target) {
@@ -477,8 +641,66 @@ var MagicScrollDirective = class MagicScrollDirective {
477
641
  target.addEventListener("keydown", this.onKeyDown);
478
642
  this.document.addEventListener("keydown", this.onKeyDown);
479
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
+ }
480
700
  detachListeners() {
481
701
  if (!this.target) return;
702
+ this.cancelScheduledFocus();
703
+ this.cancelSmoothNavigation();
482
704
  this.target.removeEventListener("scroll", this.onScroll);
483
705
  this.target.removeEventListener("wheel", this.onUserIntent);
484
706
  this.target.removeEventListener("touchstart", this.onUserIntent);
@@ -490,6 +712,10 @@ var MagicScrollDirective = class MagicScrollDirective {
490
712
  this.userInterrupted = true;
491
713
  this.pendingDestination = null;
492
714
  this.cancelDestinationRetry();
715
+ this.cancelScheduledFocus();
716
+ this.cancelLayoutPreservation();
717
+ this.cancelSmoothNavigation();
718
+ this.pendingLayoutPreservation = false;
493
719
  };
494
720
  onKeyDown = (event) => {
495
721
  if (!(event instanceof KeyboardEvent)) return;
@@ -501,12 +727,40 @@ var MagicScrollDirective = class MagicScrollDirective {
501
727
  this.userInterrupted = true;
502
728
  this.pendingDestination = null;
503
729
  this.cancelDestinationRetry();
730
+ this.cancelScheduledFocus();
731
+ this.cancelLayoutPreservation();
732
+ this.cancelSmoothNavigation();
733
+ this.pendingLayoutPreservation = false;
504
734
  }
505
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();
754
+ };
506
755
  onScroll = () => {
507
756
  if (!this.initialized) return;
508
- const anchor = this.calculateNearestAnchor();
509
- 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
+ }
510
764
  clearTimeout(this.timer);
511
765
  this.timer = setTimeout(() => this.scrollRevision.update((revision) => revision + 1), this.options().debounceTime);
512
766
  };
@@ -514,6 +768,42 @@ var MagicScrollDirective = class MagicScrollDirective {
514
768
  this.refreshTarget();
515
769
  if (this.options().preserveOnResize) this.preserve();
516
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
+ }
517
807
  static ɵfac = i0.ɵɵngDeclareFactory({
518
808
  minVersion: "12.0.0",
519
809
  version: "22.2.1",