@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
|
-
|
|
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
|
|
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 `
|
|
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
|
-
##
|
|
76
|
+
## Anchor selection and explicit anchors
|
|
76
77
|
|
|
77
|
-
|
|
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 (
|
|
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, {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
344
|
-
return Array.from(this.host.querySelectorAll("[id]")).filter((el) =>
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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))
|
|
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
|
-
|
|
414
|
-
|
|
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",
|