@devzwo/ngx-magic-scroll 0.0.1-rc.2 → 1.0.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
  }
@@ -163,7 +172,8 @@ i0.ɵɵngDeclareClassMetadata({
163
172
  }]
164
173
  });
165
174
  const defaults = {
166
- anchorPrefix: "",
175
+ anchorPrefixes: [""],
176
+ anchorSelection: "all",
167
177
  headerSelector: "",
168
178
  headerOffset: 0,
169
179
  bottomOffset: 0,
@@ -179,7 +189,8 @@ const defaults = {
179
189
  };
180
190
  function resolveMagicScrollOptions(parent, options) {
181
191
  return {
182
- anchorPrefix: options.anchorPrefix ?? parent.anchorPrefix,
192
+ anchorPrefixes: options.anchorPrefixes ?? parent.anchorPrefixes,
193
+ anchorSelection: options.anchorSelection ?? parent.anchorSelection,
183
194
  headerSelector: options.headerSelector ?? parent.headerSelector,
184
195
  headerOffset: options.headerOffset ?? parent.headerOffset,
185
196
  bottomOffset: options.bottomOffset ?? parent.bottomOffset,
@@ -263,6 +274,7 @@ var MagicScrollDirective = class MagicScrollDirective {
263
274
  host = inject(ElementRef).nativeElement;
264
275
  service = inject(ScrollService);
265
276
  nearest = inject(NearestAnchorProvider);
277
+ router = inject(Router, { optional: true });
266
278
  fragment = linkedRouteFragment();
267
279
  initialAnchor = inject(ActivatedRoute).snapshot.fragment;
268
280
  measuredHeaderHeight = signal(0, ...ngDevMode ? [{ debugName: "measuredHeaderHeight" }] : /* istanbul ignore next */ []);
@@ -273,8 +285,12 @@ var MagicScrollDirective = class MagicScrollDirective {
273
285
  scrollRevision = signal(0, ...ngDevMode ? [{ debugName: "scrollRevision" }] : /* istanbul ignore next */ []);
274
286
  initialized = false;
275
287
  userInterrupted = false;
288
+ pendingDestination = this.initialAnchor ?? null;
289
+ lastProcessedFragment = void 0;
276
290
  target;
277
291
  timer;
292
+ retryObserver;
293
+ retryTimeoutTimer;
278
294
  constructor() {
279
295
  const destroyRef = inject(DestroyRef);
280
296
  afterNextRender(() => {
@@ -285,6 +301,8 @@ var MagicScrollDirective = class MagicScrollDirective {
285
301
  this.detachListeners();
286
302
  this.document.defaultView?.removeEventListener("resize", this.onResize);
287
303
  clearTimeout(this.timer);
304
+ this.cancelDestinationRetry();
305
+ this.pendingDestination = null;
288
306
  });
289
307
  afterRenderEffect((onCleanup) => {
290
308
  const selector = this.options().headerSelector;
@@ -309,15 +327,42 @@ var MagicScrollDirective = class MagicScrollDirective {
309
327
  untracked(() => {
310
328
  if (!this.initialized) {
311
329
  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");
330
+ if (!this.userInterrupted) {
331
+ if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) {
332
+ if (this.pendingDestination) this.scheduleDestinationRetry();
333
+ }
334
+ } else {
335
+ this.liveAnchor.set(this.calculateNearestAnchor() ?? null);
336
+ this.cancelDestinationRetry();
337
+ }
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
+ }
315
344
  });
316
345
  });
317
346
  effect(() => {
318
347
  const frag = this.fragment();
319
348
  untracked(() => {
349
+ const prev = this.lastProcessedFragment;
350
+ this.lastProcessedFragment = frag;
320
351
  this.liveAnchor.set(frag);
352
+ if (prev === void 0) return;
353
+ if (frag) {
354
+ if (!this.isInternalNavigation()) {
355
+ this.userInterrupted = false;
356
+ this.pendingDestination = frag;
357
+ const state = readScrollSource(this.scrollSource());
358
+ if (this.initialized && state.ready) {
359
+ if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) this.scheduleDestinationRetry();
360
+ } else this.cancelDestinationRetry();
361
+ }
362
+ } else {
363
+ this.pendingDestination = null;
364
+ this.cancelDestinationRetry();
365
+ }
321
366
  });
322
367
  });
323
368
  effect(() => {
@@ -326,6 +371,12 @@ var MagicScrollDirective = class MagicScrollDirective {
326
371
  if (anchor) this.fragment.set(anchor);
327
372
  });
328
373
  }
374
+ isInternalNavigation() {
375
+ const raw = typeof this.router?.getCurrentNavigation === "function" ? this.router.getCurrentNavigation() ?? this.router.lastSuccessfulNavigation : this.router?.lastSuccessfulNavigation;
376
+ const nav = typeof raw === "function" ? raw() : raw ?? null;
377
+ const stateOrigin = nav?.extras?.state?.[MAGIC_SCROLL_ORIGIN_KEY];
378
+ return nav?.extras?.info?.[MAGIC_SCROLL_ORIGIN_KEY] === "scroll-spy" || nav?.trigger !== "popstate" && stateOrigin === "scroll-spy";
379
+ }
329
380
  scrollTo(anchor, options = {}) {
330
381
  const executed = this.execute(anchor, {
331
382
  behavior: this.options().behavior.interaction,
@@ -335,13 +386,51 @@ var MagicScrollDirective = class MagicScrollDirective {
335
386
  if (executed) {
336
387
  const clean = anchor.replace(/^#/, "");
337
388
  this.liveAnchor.set(clean);
389
+ this.pendingDestination = null;
390
+ this.cancelDestinationRetry();
338
391
  this.fragment.set(clean);
339
392
  }
340
393
  return executed;
341
394
  }
395
+ tryExecutePendingDestination(behavior) {
396
+ if (!this.pendingDestination) return false;
397
+ if (this.execute(this.pendingDestination, { behavior })) {
398
+ this.pendingDestination = null;
399
+ this.cancelDestinationRetry();
400
+ return true;
401
+ }
402
+ return false;
403
+ }
404
+ scheduleDestinationRetry() {
405
+ if (!this.retryObserver) {
406
+ this.retryObserver = new MutationObserver(() => {
407
+ if (readScrollSource(this.scrollSource()).ready) this.tryExecutePendingDestination(this.options().behavior.restoration);
408
+ });
409
+ this.retryObserver.observe(this.host, {
410
+ childList: true,
411
+ subtree: true,
412
+ attributes: true,
413
+ attributeFilter: ["id", "data-scroll-anchor"]
414
+ });
415
+ }
416
+ clearTimeout(this.retryTimeoutTimer);
417
+ this.retryTimeoutTimer = setTimeout(() => this.cancelDestinationRetry(), 1e3);
418
+ }
419
+ cancelDestinationRetry() {
420
+ if (this.retryObserver) {
421
+ this.retryObserver.disconnect();
422
+ this.retryObserver = void 0;
423
+ }
424
+ clearTimeout(this.retryTimeoutTimer);
425
+ this.retryTimeoutTimer = void 0;
426
+ }
342
427
  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);
428
+ const { anchorSelection, anchorPrefixes } = this.options();
429
+ return Array.from(this.host.querySelectorAll("[id]")).filter((el) => {
430
+ if (!el.id || el.closest("[magicScroll]") !== this.host) return false;
431
+ if (anchorSelection === "explicit") return el.hasAttribute("data-scroll-anchor");
432
+ return el.hasAttribute("data-scroll-anchor") || anchorPrefixes.some((prefix) => el.id.startsWith(prefix));
433
+ });
345
434
  }
346
435
  scrollable() {
347
436
  const overflow = getComputedStyle(this.host).overflowY;
@@ -399,6 +488,8 @@ var MagicScrollDirective = class MagicScrollDirective {
399
488
  }
400
489
  onUserIntent = () => {
401
490
  this.userInterrupted = true;
491
+ this.pendingDestination = null;
492
+ this.cancelDestinationRetry();
402
493
  };
403
494
  onKeyDown = (event) => {
404
495
  if (!(event instanceof KeyboardEvent)) return;
@@ -406,7 +497,11 @@ var MagicScrollDirective = class MagicScrollDirective {
406
497
  if (!SCROLL_KEYS.has(event.key)) return;
407
498
  const targetEl = event.target;
408
499
  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;
500
+ if (!targetEl || targetEl === this.document.body || targetEl === this.document.documentElement || (scrollable ? scrollable === targetEl || scrollable.contains(targetEl) : true)) {
501
+ this.userInterrupted = true;
502
+ this.pendingDestination = null;
503
+ this.cancelDestinationRetry();
504
+ }
410
505
  };
411
506
  onScroll = () => {
412
507
  if (!this.initialized) return;
@@ -1 +1 @@
1
- {"version":3,"file":"devzwo-ngx-magic-scroll.mjs","names":[],"sources":["../../../projects/ngx-magic-scroll/src/lib/navigation/linked-route-fragment.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/inject-scrollable-parent.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/inject-header-size.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/nearest-anchor.provider.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/scroll-service.ts","../../../projects/ngx-magic-scroll/src/lib/facade/magic-scroll-options.ts","../../../projects/ngx-magic-scroll/src/lib/facade/scroll-source.ts","../../../projects/ngx-magic-scroll/src/lib/facade/magic-scroll.directive.ts","../../../projects/ngx-magic-scroll/src/lib/facade/scroll-anchor.directive.ts"],"sourcesContent":["import { effect, inject, linkedSignal, WritableSignal } from '@angular/core';\nimport { toSignal } from '@angular/core/rxjs-interop';\nimport { ActivatedRoute, Router } from '@angular/router';\n\n/**\n * Creates an Angular signal synchronized bidirectionally with the URL fragment (#fragment).\n *\n * Reads the current route fragment as a writable signal (WritableSignal).\n * - Reading reacts to URL fragment changes through ActivatedRoute.fragment.\n * - Writing, for example fragmentSignal.set('anchor-id'), asynchronously updates the URL\n * through Router.navigateByUrl with replaceUrl: true. This preserves query parameters\n * and avoids adding a history entry for each scroll update.\n *\n * @returns A WritableSignal<string | null | undefined> linked to the URL fragment.\n * @example\n * ```ts\n * @Component({ ... })\n * export class MyViewComponent {\n * // Initialize the signal\n * protected readonly routeFragment = linkedRouteFragment();\n *\n * onAnchorReached(anchorId: string) {\n * // Update the URL fragment without adding history entries (#anchorId)\n * this.routeFragment.set(anchorId);\n * }\n * }\n * ```\n */\nexport function linkedRouteFragment(): WritableSignal<string | null | undefined> {\n const route = inject(ActivatedRoute);\n const router = inject(Router);\n const fragment = toSignal(route.fragment);\n\n const actualFragment = linkedSignal(() => fragment());\n\n effect(() => {\n const newFragment = actualFragment();\n if (route.snapshot.fragment !== newFragment) {\n const tree = router.createUrlTree([], {\n relativeTo: route,\n fragment: newFragment ?? undefined,\n queryParamsHandling: 'preserve',\n });\n\n router.navigateByUrl(tree, { replaceUrl: true }).catch((error: Error) => {\n console.log(`Cant set fragment ${newFragment}`, error);\n });\n }\n });\n\n return actualFragment;\n}\n","import { ElementRef, inject } from '@angular/core';\n\n/**\n * Finds the closest scrollable ancestor in the DOM.\n *\n * Walks up the DOM tree looking for overflow-y: auto or overflow-y: scroll.\n * Call within an Angular injection context to start at ElementRef.nativeElement,\n * or pass a hostElement explicitly.\n *\n * @param hostElement - Optional starting element. An explicit element takes precedence;\n * otherwise the starting element is obtained by injecting ElementRef<HTMLElement>.\n * @returns The closest scrollable HTMLElement, or undefined if none exists.\n * @example\n * ```ts\n * // Within an injection context (for example a directive or component constructor):\n * const scrollParent = injectScrollableParentElement();\n *\n * // Outside an injection context:\n * const scrollParent = injectScrollableParentElement(myHtmlElement);\n * ```\n */\nexport function injectScrollableParentElement(\n hostElement?: HTMLElement | null,\n): HTMLElement | undefined {\n function findScrollableParent(el: HTMLElement): HTMLElement | undefined {\n let current = el.parentElement ?? undefined;\n while (current) {\n const overflowY = getComputedStyle(current).overflowY;\n if (\n overflowY === 'auto' ||\n overflowY === 'scroll' /**&& current.scrollHeight > current.clientHeight **/\n ) {\n return current;\n }\n current = current.parentElement ?? undefined;\n }\n return undefined;\n }\n\n return findScrollableParent(\n hostElement ?? (inject(ElementRef<HTMLElement>).nativeElement as HTMLElement),\n );\n}\n","/**\n * Measures the height of a header selected within the supplied document.\n * Used to calculate scroll and anchor offsets so a fixed or sticky header does not\n * cover the scrolled content.\n *\n * @param headerSelector - Header CSS selector, such as '.header' or 'app-header'.\n * @param document - Document instance in which to search, for example injected through DOCUMENT.\n * @returns The absolute header height in pixels, or 0 if the element is not found.\n * @example\n * ```ts\n * const document = inject(DOCUMENT);\n * const height = injectedHeaderHeight('.header', document);\n * ```\n */\nexport function injectedHeaderHeight(headerSelector: string, document: Document): number {\n const boundingBox = document.querySelector<HTMLElement>(headerSelector)?.getBoundingClientRect();\n if (!boundingBox) {\n console.warn('No boundingBox found for headerSelector: ', headerSelector);\n return 0;\n }\n\n return Math.abs(boundingBox.height);\n}\n","import { DOCUMENT } from '@angular/common';\nimport { inject, Injectable } from '@angular/core';\nimport { injectScrollableParentElement } from './inject-scrollable-parent';\nimport { injectedHeaderHeight } from './inject-header-size';\n\n/**\n * Options for finding the nearest anchor.\n */\nexport interface NearestAnchorOptions {\n /** Explicit scroll container, including a host that is itself scrollable. */\n scrollable?: HTMLElement;\n /** Preselected anchor elements (e.g. scoped, explicit anchors in the facade). */\n anchors?: readonly HTMLElement[];\n /**\n * Optional host element within which to find anchor elements.\n * Defaults to searching the entire Document.\n */\n hostElement?: HTMLElement;\n /**\n * CSS selector for eligible anchors, for example '[id^=\"item-card-\"]'.\n * Defaults to '[id]'.\n */\n selector?: string;\n /**\n * Static top offset, in pixels, such as a fixed gap or padding.\n */\n headerOffset?: number;\n /**\n * CSS selector for a fixed or sticky header, for example '.header'.\n * Its measured height is added to the offset so covered elements are not selected\n * as the topmost visible anchor.\n */\n headerSelector?: string;\n /**\n * Static bottom offset, in pixels, such as a sticky footer gap or bottom margin.\n */\n bottomOffset?: number;\n /**\n * Relative activation threshold (0.0 to 1.0) defining the trigger line within the visible reading area.\n */\n activationThreshold?: number;\n}\n\n/**\n * Finds the anchor representing the currently read section based on full containment and activation boundaries.\n *\n * Supports scroll-spy behavior to identify the current section while scrolling and\n * synchronize the URL fragment. A section is activated immediately when fully visible in the\n * viewport below sticky headers, or remains active while scrolling through its content until\n * the next section crosses the activation boundary or becomes fully visible.\n *\n * @remarks\n * This service does not use providedIn: 'root'. Register it in application or component\n * providers when using it internally. The facade provides its own instance.\n */\n@Injectable()\nexport class NearestAnchorProvider {\n private readonly document = inject(DOCUMENT);\n\n /**\n * Calculates the ID of the active anchor based on scroll position and visible area boundaries.\n *\n * 1. Evaluates all eligible anchors within the host or document.\n * 2. Determines the visible reading area (`topBoundary = scrollableTop + headerOffset + headerHeight`, `bottomBoundary = (containerBottom | viewportHeight) - bottomOffset`).\n * 3. Prioritizes fully contained anchors (`top >= topBoundary - 1` and `bottom <= bottomBoundary + 1`), selecting the topmost one.\n * 4. Falls back to the anchor that crossed `triggerLine` most recently (`top <= triggerLine + 1`).\n * 5. Falls back to the first anchor if no anchor has crossed the boundary yet.\n *\n * @param options - Host element, selector, scroll container and header-offset settings.\n * @returns The active anchor's element ID, or undefined if no eligible element exists.\n * @example\n * ```ts\n * const nearestId = nearestAnchorProvider.getNearestAnchor({\n * hostElement: listContainer,\n * selector: '[id^=\"card-\"]',\n * headerSelector: '.app-header',\n * bottomOffset: 40\n * });\n * ```\n */\n public getNearestAnchor(options?: NearestAnchorOptions): string | undefined {\n const host = options?.hostElement ?? this.document;\n const selector = options?.selector ?? '[id]';\n const offset =\n (options?.headerOffset ?? 0) +\n (options?.headerSelector ? injectedHeaderHeight(options.headerSelector, this.document) : 0);\n\n // Collects all HTML elements with an id – these are our potential anchor targets\n const anchors = options?.anchors ?? Array.from(host.querySelectorAll<HTMLElement>(selector));\n if (anchors.length === 0) {\n return undefined;\n }\n\n const scrollable =\n options?.scrollable ??\n (options?.hostElement ? injectScrollableParentElement(options.hostElement) : undefined);\n const isDocument =\n !scrollable ||\n scrollable === this.document.documentElement ||\n scrollable === this.document.body;\n const topBoundary = (isDocument ? 0 : scrollable.getBoundingClientRect().top) + offset;\n const viewportBottom = isDocument\n ? (this.document.defaultView?.innerHeight ?? this.document.documentElement.clientHeight)\n : scrollable.getBoundingClientRect().bottom;\n const bottomBoundary = Math.max(\n topBoundary,\n viewportBottom - Math.max(0, options?.bottomOffset ?? 0),\n );\n\n // 1. Check for fully contained anchors (entire element inside visible reading area)\n const containedAnchors = anchors.filter((el) => {\n const rect = el.getBoundingClientRect();\n return rect.top >= topBoundary - 1 && rect.bottom <= bottomBoundary + 1;\n });\n\n if (containedAnchors.length > 0) {\n // Topmost fully visible anchor\n const topmostContained = containedAnchors.reduce((best, el) =>\n el.getBoundingClientRect().top < best.getBoundingClientRect().top ? el : best,\n );\n return topmostContained.id;\n }\n\n // 2. Reading line fallback: Anchor that crossed triggerLine most recently\n const threshold = Math.max(0, Math.min(1, options?.activationThreshold ?? 0));\n const triggerLine = topBoundary + (bottomBoundary - topBoundary) * threshold;\n const passedAnchors = anchors.filter((el) => el.getBoundingClientRect().top <= triggerLine + 1);\n\n if (passedAnchors.length > 0) {\n // Pick the anchor that crossed the trigger line most recently (highest top <= triggerLine)\n const active = passedAnchors.reduce((best, el) =>\n el.getBoundingClientRect().top > best.getBoundingClientRect().top ? el : best,\n );\n return active.id;\n }\n\n // 3. Before any anchor has reached the boundary, select the first anchor\n const first = anchors.reduce((best, el) =>\n el.getBoundingClientRect().top < best.getBoundingClientRect().top ? el : best,\n );\n return first.id;\n }\n}\n","import { inject, Injectable } from '@angular/core';\nimport { DOCUMENT } from '@angular/common';\n\n/**\n * Options for a scroll operation performed by ScrollService.\n */\nexport interface ScrollOptions {\n /**\n * Scrollable container. Defaults to the document's scrolling element.\n */\n scrollable?: HTMLElement;\n /**\n * Scroll behavior: 'instant' for an immediate jump, 'smooth' for animation,\n * or 'auto' to follow the browser's CSS scroll behavior. Defaults to 'instant'.\n */\n behavior?: ScrollBehavior;\n /**\n * Visibility condition under which scrolling is skipped:\n * - 'none': Always scroll (default).\n * - 'top': Skip when the element's top edge is visible.\n * - 'full': Skip when the entire element is visible.\n * - 'always': Skip when either the top or bottom edge is visible.\n */\n ignoreWhenInView?: 'none' | 'top' | 'full' | 'always';\n /**\n * Additional top offset for the target, in pixels, for example for a header or padding.\n * Defaults to 20 pixels.\n */\n topOffset?: number;\n}\n\n/**\n * Central service for scrolling to DOM elements and anchors.\n *\n * Supports explicit scroll containers or document scrolling, configurable header offsets,\n * visibility checks to avoid unnecessary movement, and smooth or instant scrolling.\n */\n@Injectable({\n providedIn: 'root',\n})\nexport class ScrollService {\n private readonly document = inject<Document>(DOCUMENT);\n private readonly TOP_OFFSET = 20;\n\n /**\n * Scrolls to the element identified by its ID.\n *\n * @param anchor - Target HTML element ID, with or without a leading #.\n * @param options - Scroll container, behavior, offset and visibility settings.\n * @returns True when the target exists and scrolling is initiated; otherwise false.\n * @example\n * ```ts\n * // Simple scrolling\n * scrollService.scroll('section-details');\n *\n * // With options\n * scrollService.scroll('card-123', {\n * scrollable: scrollContainerElement,\n * behavior: 'smooth',\n * topOffset: 80,\n * ignoreWhenInView: 'top'\n * });\n * ```\n */\n scroll(anchor: string, options?: ScrollOptions): boolean {\n const elementId = anchor.startsWith('#') ? anchor.slice(1) : anchor;\n const el = this.document.getElementById(elementId);\n\n if (!el) {\n return false;\n }\n\n const scrollable =\n options?.scrollable ??\n (this.document.scrollingElement as HTMLElement | null) ??\n this.document.documentElement;\n const behavior = options?.behavior ?? 'instant';\n const ignoreWhenInView = options?.ignoreWhenInView ?? 'none';\n const topOffset = options?.topOffset ?? this.TOP_OFFSET;\n\n const rect = el.getBoundingClientRect();\n const isDocument =\n scrollable === this.document.documentElement || scrollable === this.document.body;\n const containerTop = isDocument ? 0 : scrollable.getBoundingClientRect().top;\n const viewportHeight = isDocument\n ? this.document.documentElement.clientHeight\n : scrollable.clientHeight;\n const isTopInView =\n rect.top >= containerTop + topOffset - 1 && rect.top < containerTop + viewportHeight;\n const isBottomInView =\n rect.bottom <= containerTop + viewportHeight && rect.bottom > containerTop;\n\n if (\n (ignoreWhenInView == 'top' && isTopInView) ||\n (ignoreWhenInView == 'full' && isTopInView && isBottomInView) ||\n (ignoreWhenInView == 'always' && (isTopInView || isBottomInView))\n ) {\n return false;\n }\n\n // One operation keeps offsets accurate during smooth scrolling and only moves the selected container.\n scrollable.scrollTo({\n top: scrollable.scrollTop + rect.top - containerTop - topOffset,\n behavior,\n });\n\n return true;\n }\n}\n","import { inject, InjectionToken, Provider } from '@angular/core';\nimport { ScrollOptions } from '../navigation/scroll-service';\n\n/** Defaults for an application or overrides for one `magicScroll` region. */\nexport interface MagicScrollOptions {\n /** All descendant IDs participate when empty. Explicit `scrollAnchor`s also participate. */\n anchorPrefix?: string;\n /** Selector for a fixed/sticky header whose height is measured automatically. */\n headerSelector?: string;\n /** Gap below the header, or the complete top offset when no header is selected. */\n headerOffset?: number;\n /** Bottom offset in pixels to adjust the active reading boundary from the bottom. */\n bottomOffset?: number;\n /** Relative activation threshold (0.0 to 1.0) defining the trigger line within the visible reading area. */\n activationThreshold?: number;\n /** Restoration and deliberate user navigation have separate defaults. */\n behavior?: {\n restoration?: ScrollBehavior;\n interaction?: ScrollBehavior;\n };\n debounceTime?: number;\n ignoreWhenInView?: ScrollOptions['ignoreWhenInView'];\n /** Keep the current anchor visible after later data updates. Defaults to true. */\n restoreOnDataChange?: boolean;\n /** Preserve the current anchor on window resize and measured header-height changes. */\n preserveOnResize?: boolean;\n}\n\nexport interface ResolvedMagicScrollOptions {\n anchorPrefix: string;\n headerSelector: string;\n headerOffset: number;\n bottomOffset: number;\n activationThreshold: number;\n behavior: { restoration: ScrollBehavior; interaction: ScrollBehavior };\n debounceTime: number;\n ignoreWhenInView: NonNullable<ScrollOptions['ignoreWhenInView']>;\n restoreOnDataChange: boolean;\n preserveOnResize: boolean;\n}\n\nconst defaults: ResolvedMagicScrollOptions = {\n anchorPrefix: '',\n headerSelector: '',\n headerOffset: 0,\n bottomOffset: 0,\n activationThreshold: 0,\n behavior: { restoration: 'instant', interaction: 'smooth' },\n debounceTime: 500,\n ignoreWhenInView: 'none',\n restoreOnDataChange: true,\n preserveOnResize: true,\n};\n\nexport function resolveMagicScrollOptions(\n parent: ResolvedMagicScrollOptions,\n options: MagicScrollOptions,\n): ResolvedMagicScrollOptions {\n return {\n anchorPrefix: options.anchorPrefix ?? parent.anchorPrefix,\n headerSelector: options.headerSelector ?? parent.headerSelector,\n headerOffset: options.headerOffset ?? parent.headerOffset,\n bottomOffset: options.bottomOffset ?? parent.bottomOffset,\n activationThreshold: options.activationThreshold ?? parent.activationThreshold,\n behavior: {\n restoration: options.behavior?.restoration ?? parent.behavior.restoration,\n interaction: options.behavior?.interaction ?? parent.behavior.interaction,\n },\n debounceTime: options.debounceTime ?? parent.debounceTime,\n ignoreWhenInView: options.ignoreWhenInView ?? parent.ignoreWhenInView,\n restoreOnDataChange: options.restoreOnDataChange ?? parent.restoreOnDataChange,\n preserveOnResize: options.preserveOnResize ?? parent.preserveOnResize,\n };\n}\n\nexport const MAGIC_SCROLL_OPTIONS = new InjectionToken<ResolvedMagicScrollOptions>(\n 'Magic scroll defaults',\n { providedIn: 'root', factory: () => defaults },\n);\n\n/**\n * Register defaults globally or on a component. Child providers inherit unspecified options.\n * Per-region `scrollOptions` and per-call `scrollTo` options take precedence.\n *\n * @example\n * provideMagicScroll({ anchorPrefix: 'project-', behavior: { interaction: 'smooth' } })\n */\nexport function provideMagicScroll(options: MagicScrollOptions = {}): Provider {\n return {\n provide: MAGIC_SCROLL_OPTIONS,\n useFactory: () =>\n resolveMagicScrollOptions(\n inject(MAGIC_SCROLL_OPTIONS, { skipSelf: true, optional: true }) ?? defaults,\n options,\n ),\n };\n}\n","import { isSignal, Signal } from '@angular/core';\nimport { ScrollDataSource } from '../navigation/scroll-data-source';\n\n/** Structural Angular resource contract; no dependency on a data-fetching provider. */\nexport interface ScrollResource {\n readonly value: () => unknown;\n readonly isLoading: () => boolean;\n readonly hasValue?: () => boolean;\n}\n\n/**\n * Pass a signal (also computed/input/model signals), a resource/rxResource, or\n * reactive data()/isLoading() getters. Plain input values are supported too.\n * For signal forms pass the field's value signal, e.g. `form().value`.\n * Supabase data and loading signals can use the neutral ScrollDataSource contract.\n */\nexport type ScrollSource =\n | Signal<unknown>\n | ScrollResource\n | ScrollDataSource\n | object\n | string\n | number\n | boolean\n | null\n | undefined;\n\nexport const NO_SCROLL_SOURCE = Symbol('Static, already rendered data');\n\n/** Internal readiness adapter. null/undefined wait; [], 0, false and '' are ready. */\nexport function readScrollSource(source: ScrollSource | typeof NO_SCROLL_SOURCE): {\n ready: boolean;\n data: unknown;\n} {\n if (source === NO_SCROLL_SOURCE) return { ready: true, data: source };\n let data: unknown = source;\n while (isSignal(data)) data = data();\n if (data !== null && typeof data === 'object') {\n // Query results may be proxies whose `has` trap reflects the current value.\n // Read the loading getter directly: `isLoading in source` can be false when idle.\n const candidate = data as { isLoading?: unknown; data?: unknown };\n if (typeof candidate.isLoading === 'function') {\n if (candidate.isLoading()) return { ready: false, data: undefined };\n if ('value' in data && typeof data.value === 'function') {\n const resource = data as ScrollResource;\n // Reading an Angular resource's value in an error state can throw.\n data = resource.hasValue && !resource.hasValue() ? undefined : resource.value();\n } else if (typeof candidate.data === 'function') {\n data = candidate.data();\n }\n }\n }\n return { ready: data !== undefined && data !== null, data };\n}\n","import { DOCUMENT } from '@angular/common';\nimport {\n afterNextRender,\n afterRenderEffect,\n computed,\n DestroyRef,\n Directive,\n effect,\n ElementRef,\n inject,\n input,\n signal,\n untracked,\n} from '@angular/core';\nimport { ActivatedRoute } from '@angular/router';\nimport { linkedRouteFragment } from '../navigation/linked-route-fragment';\nimport { injectScrollableParentElement } from '../navigation/inject-scrollable-parent';\nimport { NearestAnchorProvider } from '../navigation/nearest-anchor.provider';\nimport { ScrollOptions, ScrollService } from '../navigation/scroll-service';\nimport {\n MAGIC_SCROLL_OPTIONS,\n MagicScrollOptions,\n resolveMagicScrollOptions,\n} from './magic-scroll-options';\nimport { NO_SCROLL_SOURCE, readScrollSource, ScrollSource } from './scroll-source';\n\n/**\n * Per-action scroll options for programmatic navigation via {@link MagicScrollDirective.scrollTo}.\n * Top offsets and container scoping are resolved automatically by the directive facade.\n */\nexport type MagicScrollToOptions = Pick<\n ScrollOptions,\n 'behavior' | 'topOffset' | 'ignoreWhenInView'\n>;\n\n/**\n * Standard keyboard navigation keys that manipulate viewport scroll position.\n * Used by {@link MagicScrollDirective} to detect manual user scroll intent and cancel\n * pending automatic initial restorations prior to asynchronous data loading readiness.\n */\nconst SCROLL_KEYS = new Set([\n 'ArrowUp',\n 'ArrowDown',\n 'PageUp',\n 'PageDown',\n 'Home',\n 'End',\n ' ',\n 'Spacebar',\n 'Space',\n]);\n\n/**\n * Determines whether a keyboard event target is an interactive or editable text control.\n * Ensures typing and navigating within input fields, textareas, dropdowns, and contenteditable\n * areas do not mistakenly cancel pending initial scroll restoration.\n */\nfunction isEditableTarget(target: EventTarget | null): boolean {\n if (!target || !(target instanceof Element)) return false;\n const tagName = target.tagName.toLowerCase();\n if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') return true;\n const editableHost = target.closest('[contenteditable]');\n const editableValue = editableHost?.getAttribute('contenteditable')?.toLowerCase();\n return Boolean(\n (target as HTMLElement).isContentEditable ||\n target.closest('input, textarea, select') ||\n editableValue === '' ||\n editableValue === 'true' ||\n editableValue === 'plaintext-only',\n );\n}\n\n/**\n * Complete facade directive coordinating anchor scrolling, two-tier route synchronization,\n * user-intent cancellation, data readiness restoration, and layout/data position preservation.\n *\n * @remarks\n * ### Architectural Pillars:\n * 1. **Two-Tier Anchor State Model**:\n * - `liveAnchor`: Immediate per-scroll evaluation representing the visible section currently in view.\n * Used as the source of truth for immediate data and layout realignments.\n * - `fragment` / `activeAnchor`: Debounced (default 500ms) URL router synchronization preventing browser\n * history thrashing during continuous scrolling.\n * 2. **User Intent Detection & Cancellation**:\n * - Captures manual user gestures (`wheel`, `touchstart`, `touchmove`, navigation `keydown`) occurring prior\n * to asynchronous `scrollSource` readiness.\n * - Invalidates pending delayed initial restorations so conscious user navigation is never overridden.\n * - Safely excludes editable form controls (`<input>`, `<textarea>`, `contenteditable`).\n * 3. **Reactive Render Pipeline & Lifecycle Coordination**:\n * - Sequenced `afterRenderEffect` execution blocks guarantee sticky header measurement and `ResizeObserver`\n * monitoring are active before initial fragment restoration and data preservation execute.\n * 4. **Preservation & Realignment Engine**:\n * - Automatic realignments (`preserve('top')`) on data updates and container/header resizes based on the live active section.\n * 5. **Container & Anchor Scoping**:\n * - Automatic scroll container resolution (window vs scrollable element) and hierarchical anchor ownership isolation.\n *\n * @example\n * ```html\n * <div magicScroll #scroll=\"magicScroll\" [scrollSource]=\"projects\">\n * <section id=\"project-1\">...</section>\n * <section id=\"project-2\">...</section>\n * </div>\n * <button (click)=\"scroll.scrollTo('project-2')\">Go to Project 2</button>\n * ```\n */\n@Directive({\n selector: '[magicScroll]',\n exportAs: 'magicScroll',\n providers: [NearestAnchorProvider],\n host: { '[attr.magicScroll]': '\"\"' },\n})\nexport class MagicScrollDirective {\n /**\n * Asynchronous data source or signal determining scroll readiness.\n * - When omitted (`NO_SCROLL_SOURCE`), initial restoration executes on the initial rendered DOM.\n * - When provided, initial restoration waits until `readScrollSource(source).ready === true`.\n * Subsequent ready data updates trigger automatic position preservation when `restoreOnDataChange` is enabled.\n */\n readonly scrollSource = input<ScrollSource | typeof NO_SCROLL_SOURCE, ScrollSource>(\n NO_SCROLL_SOURCE,\n { transform: (value) => value },\n );\n\n /** Configuration overrides merged with global `provideMagicScroll` options. */\n readonly scrollOptions = input<MagicScrollOptions>({});\n\n private readonly defaults = inject(MAGIC_SCROLL_OPTIONS);\n private readonly options = computed(() =>\n resolveMagicScrollOptions(this.defaults, this.scrollOptions()),\n );\n private readonly document = inject(DOCUMENT);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;\n private readonly service = inject(ScrollService);\n private readonly nearest = inject(NearestAnchorProvider);\n\n /** Bidirectional route fragment synchronization signal (debounced URL writer and listener). */\n private readonly fragment = linkedRouteFragment();\n\n /** Initial route URL fragment snapshot captured upon directive instantiation. */\n private readonly initialAnchor = inject(ActivatedRoute).snapshot.fragment;\n\n /** Dynamically measured header height via ResizeObserver. */\n private readonly measuredHeaderHeight = signal(0);\n\n /**\n * Real-time visible section identifier updated per scroll event.\n * Serves as the source of truth for immediate data and layout realignments (`preserve`),\n * decoupling preservation from the debounced URL fragment signal.\n */\n private readonly liveAnchor = signal<string | null | undefined>(null);\n\n /** Current URL anchor, suitable for active-navigation highlighting. */\n readonly activeAnchor = this.fragment.asReadonly();\n\n /** Measured header height, useful for sticky companion panels. */\n readonly headerHeight = this.measuredHeaderHeight.asReadonly();\n\n /** Complete effective top offset, including header height and configured gap. */\n readonly offset = computed(() => this.headerHeight() + this.options().headerOffset);\n\n /** Monotonically increasing revision counter triggering debounced URL fragment updates. */\n private readonly scrollRevision = signal(0);\n\n /** Flag indicating whether the initial restoration phase has been executed. */\n private initialized = false;\n\n /**\n * Flag indicating whether manual user interaction (wheel, touch, or navigation keys) occurred\n * before asynchronous data loading became ready. When true, delayed initial restoration is skipped.\n */\n private userInterrupted = false;\n\n private target?: EventTarget;\n private timer?: ReturnType<typeof setTimeout>;\n\n constructor() {\n const destroyRef = inject(DestroyRef);\n afterNextRender(() => {\n this.refreshTarget();\n this.document.defaultView?.addEventListener('resize', this.onResize);\n });\n destroyRef.onDestroy(() => {\n this.detachListeners();\n this.document.defaultView?.removeEventListener('resize', this.onResize);\n clearTimeout(this.timer);\n });\n\n // Phase 1: Header measurement & ResizeObserver tracking.\n // Sequenced first so accurate header dimensions and offsets are available before initial restoration.\n afterRenderEffect((onCleanup) => {\n const selector = this.options().headerSelector;\n const header = selector ? this.document.querySelector<HTMLElement>(selector) : null;\n const measure = () => {\n const height = header?.getBoundingClientRect().height ?? 0;\n if (height !== this.headerHeight()) {\n this.measuredHeaderHeight.set(height);\n if (this.options().preserveOnResize) this.preserve();\n }\n };\n untracked(measure);\n if (header) {\n const observer = new ResizeObserver(measure);\n observer.observe(header);\n onCleanup(() => observer.disconnect());\n }\n });\n\n // Phase 2: Asynchronous scrollSource readiness, initial restoration, and data-change preservation.\n // Untracked execution guarantees execution triggers strictly on scrollSource readiness transitions.\n afterRenderEffect(() => {\n const source = this.scrollSource();\n const state = readScrollSource(source);\n if (!state.ready) return;\n untracked(() => {\n if (!this.initialized) {\n this.initialized = true;\n if (!this.userInterrupted) {\n this.execute(this.initialAnchor, { behavior: this.options().behavior.restoration });\n } else {\n this.liveAnchor.set(this.calculateNearestAnchor() ?? null);\n }\n } else if (source !== NO_SCROLL_SOURCE && this.options().restoreOnDataChange) {\n this.preserve('top');\n }\n });\n });\n\n // Phase 3: Route fragment synchronization to liveAnchor.\n // When the route fragment changes externally (e.g., router navigation), synchronize liveAnchor.\n effect(() => {\n const frag = this.fragment();\n untracked(() => {\n this.liveAnchor.set(frag);\n });\n });\n\n // Phase 4: Scroll revision debounced publication to URL fragment.\n // Re-evaluates nearest anchor and commits it to the URL router after debounce delay.\n effect(() => {\n if (!this.scrollRevision()) return;\n const anchor = this.calculateNearestAnchor();\n if (anchor) this.fragment.set(anchor);\n });\n }\n\n /**\n * Explicitly scrolls the viewport to the target anchor identifier.\n *\n * @param anchor - Target anchor ID (with or without leading `#`).\n * @param options - Per-call scroll overrides (behavior, top offset, ignoreWhenInView).\n * @returns `true` if the target anchor was found and scrolling initiated; `false` otherwise.\n *\n * @remarks\n * Successful execution immediately synchronizes both `liveAnchor` and the router `fragment`\n * with the target anchor, overriding any in-flight debounce delays.\n */\n scrollTo(anchor: string, options: MagicScrollToOptions = {}): boolean {\n const executed = this.execute(anchor, {\n behavior: this.options().behavior.interaction,\n ignoreWhenInView: this.options().ignoreWhenInView,\n ...options,\n });\n if (executed) {\n const clean = anchor.replace(/^#/, '');\n this.liveAnchor.set(clean);\n this.fragment.set(clean);\n }\n return executed;\n }\n\n /**\n * Queries and returns all eligible scroll anchor elements belonging strictly to this directive instance.\n *\n * @remarks\n * Anchors must possess a non-empty `id` and either match the configured `anchorPrefix` or contain\n * the `data-scroll-anchor` attribute. The `closest('[magicScroll]') === this.host` check prevents\n * nested or parent `magicScroll` directive instances from capturing child anchors across boundaries.\n */\n private anchors(): HTMLElement[] {\n const prefix = this.options().anchorPrefix;\n return Array.from(this.host.querySelectorAll<HTMLElement>('[id]')).filter(\n (el) =>\n !!el.id &&\n (el.hasAttribute('data-scroll-anchor') || el.id.startsWith(prefix)) &&\n el.closest('[magicScroll]') === this.host,\n );\n }\n\n /**\n * Resolves the active scroll container element governing viewport offsets.\n *\n * @returns The scrollable container `HTMLElement`, or `undefined` when scrolling against the document root / window.\n */\n private scrollable(): HTMLElement | undefined {\n const overflow = getComputedStyle(this.host).overflowY;\n const container =\n overflow === 'auto' || overflow === 'scroll'\n ? this.host\n : injectScrollableParentElement(this.host);\n // Root scrolling emits events on document, even when overflow is set on body/html.\n return container === this.document.documentElement || container === this.document.body\n ? undefined\n : container;\n }\n\n /**\n * Computes the nearest visible anchor according to current viewport geometry, header offsets,\n * bottom offsets, and reading direction thresholds.\n */\n private calculateNearestAnchor(): string | undefined {\n return this.nearest.getNearestAnchor({\n hostElement: this.host,\n anchors: this.anchors(),\n scrollable: this.scrollable(),\n headerOffset: this.offset(),\n bottomOffset: this.options().bottomOffset,\n activationThreshold: this.options().activationThreshold,\n });\n }\n\n /**\n * Dispatches scrolling to the underlying {@link ScrollService} after validating target presence.\n */\n private execute(anchor: string | null | undefined, options: MagicScrollToOptions): boolean {\n if (!anchor || !this.anchors().some((el) => el.id === anchor.replace(/^#/, ''))) return false;\n return this.service.scroll(anchor, {\n scrollable: this.scrollable(),\n topOffset: this.offset(),\n ...options,\n });\n }\n\n /**\n * Realigns the viewport instantly to preserve position during asynchronous data updates\n * or container / header dimension changes.\n *\n * @remarks\n * Targets `liveAnchor` (the real-time visible section) rather than `fragment` (the debounced URL fragment)\n * to ensure rapid data or layout shifts during continuous scrolling maintain the user's active view\n * rather than jumping back to a stale URL anchor.\n */\n private preserve(ignoreWhenInView: ScrollOptions['ignoreWhenInView'] = 'none'): void {\n if (this.initialized) {\n this.execute(this.liveAnchor(), { behavior: 'instant', ignoreWhenInView });\n }\n }\n\n /**\n * Updates target container references and re-binds passive event listeners whenever\n * the scroll container shifts dynamically.\n */\n private refreshTarget(): void {\n const target = this.scrollable() ?? this.document;\n if (target !== this.target) {\n this.detachListeners();\n this.target = target;\n this.attachListeners(target);\n }\n }\n\n /**\n * Attaches passive scroll, gesture, and keyboard listeners to track user intent and scroll motion.\n */\n private attachListeners(target: EventTarget): void {\n target.addEventListener('scroll', this.onScroll);\n target.addEventListener('wheel', this.onUserIntent, { passive: true });\n target.addEventListener('touchstart', this.onUserIntent, { passive: true });\n target.addEventListener('touchmove', this.onUserIntent, { passive: true });\n target.addEventListener('keydown', this.onKeyDown);\n this.document.addEventListener('keydown', this.onKeyDown);\n }\n\n /**\n * Detaches all registered event listeners to ensure clean teardown without memory leaks.\n */\n private detachListeners(): void {\n if (!this.target) return;\n this.target.removeEventListener('scroll', this.onScroll);\n this.target.removeEventListener('wheel', this.onUserIntent);\n this.target.removeEventListener('touchstart', this.onUserIntent);\n this.target.removeEventListener('touchmove', this.onUserIntent);\n this.target.removeEventListener('keydown', this.onKeyDown);\n this.document.removeEventListener('keydown', this.onKeyDown);\n }\n\n /**\n * Intercepts manual user gestures (`wheel`, `touchstart`, `touchmove`) to flag user interaction\n * and cancel delayed initial restorations prior to data readiness.\n */\n private readonly onUserIntent = () => {\n this.userInterrupted = true;\n };\n\n /**\n * Intercepts navigation keyboard events (`ArrowUp`, `ArrowDown`, `PageUp`, `PageDown`, `Home`, `End`, `Space`).\n * Cancels pending initial restoration when navigation occurs within the relevant scroll context\n * while safely ignoring interactions on editable input elements.\n */\n private readonly onKeyDown = (event: Event) => {\n if (!(event instanceof KeyboardEvent)) return;\n if (isEditableTarget(event.target)) return;\n if (!SCROLL_KEYS.has(event.key)) return;\n const targetEl = event.target as Element | null;\n const scrollable = this.scrollable();\n const isInsideContext =\n !targetEl ||\n targetEl === this.document.body ||\n targetEl === this.document.documentElement ||\n (scrollable ? scrollable === targetEl || scrollable.contains(targetEl) : true);\n if (isInsideContext) {\n this.userInterrupted = true;\n }\n };\n\n /**\n * Handles scroll events by immediately updating `liveAnchor` for position preservation,\n * while debouncing `scrollRevision` increments to update the browser URL fragment without thrashing history.\n */\n private readonly onScroll = () => {\n if (!this.initialized) return;\n const anchor = this.calculateNearestAnchor();\n if (anchor) this.liveAnchor.set(anchor);\n clearTimeout(this.timer);\n this.timer = setTimeout(\n () => this.scrollRevision.update((revision) => revision + 1),\n this.options().debounceTime,\n );\n };\n\n /**\n * Handles window resize events by re-evaluating targets and optionally realigning the active anchor.\n */\n private readonly onResize = () => {\n this.refreshTarget();\n if (this.options().preserveOnResize) this.preserve();\n };\n}\n","import { Directive, effect, ElementRef, inject, input } from '@angular/core';\n\n/**\n * Optional explicit anchor. Its ID participates even outside the configured prefix.\n * Use `scrollAnchor` alongside an existing id, or `[scrollAnchor]=\"item.id\"` to set it.\n */\n@Directive({\n selector: '[scrollAnchor]',\n host: { '[attr.data-scroll-anchor]': '\"\"' },\n})\nexport class ScrollAnchorDirective {\n readonly scrollAnchor = input('');\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n constructor() {\n effect(() => {\n const id = this.scrollAnchor();\n if (id) this.host.nativeElement.id = id;\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAgB,sBAAmB;CACjC,MAAM,QAAQ,OAAO,cAAc;CACnC,MAAM,SAAS,OAAO,MAAM;CAC5B,MAAM,WAAW,SAAS,MAAM,QAAQ;CAExC,MAAM,iBAAiB,mBAAmB,SAAQ,GAAG,GAAA,YAAA,CAAA,EAAA,WAAA,iBAAA,CAAA,+BAAA,CAAA,CAAA;CAErD,aAAY;EACV,MAAM,cAAc,eAAc;EAClC,IAAI,MAAM,SAAS,aAAa,aAAa;GAC3C,MAAM,OAAO,OAAO,cAAc,CAAA,GAAI;IACpC,YAAY;IACZ,UAAU,eAAe;IACzB,qBAAqB;GACtB,CAAA;GAED,OAAO,cAAc,MAAM,EAAE,YAAY,KAAI,CAAE,CAAC,CAAC,OAAO,UAAgB;IACtE,QAAQ,IAAI,qBAAqB,eAAe,KAAK;GACvD,CAAC;EACH;CACF,CAAC;CAED,OAAO;AACT;AC9BA,SAAgB,8BACd,aAAgC;CAEhC,SAAS,qBAAqB,IAAe;EAC3C,IAAI,UAAU,GAAG,iBAAiB;EAClC,OAAO,SAAS;GACd,MAAM,YAAY,iBAAiB,OAAO,CAAC,CAAC;GAC5C,IACE,cAAc,UACd,cAAc,UAEd,OAAO;GAET,UAAU,QAAQ,iBAAiB;EACrC;CAEF;CAEA,OAAO,qBACL,eAAgB,OAAO,UAAuB,CAAC,CAAC,aAA6B;AAEjF;AC5BA,SAAgB,qBAAqB,gBAAwB,UAAkB;CAC7E,MAAM,cAAc,SAAS,cAA2B,cAAc,CAAC,EAAE,sBAAqB;CAC9F,IAAI,CAAC,aAAa;EAChB,QAAQ,KAAK,6CAA6C,cAAc;EACxE,OAAO;CACT;CAEA,OAAO,KAAK,IAAI,YAAY,MAAM;AACpC;ACkCA,IAAa,wBAAb,MAAa,sBAAqB;CACf,WAAW,OAAO,QAAQ;CAuBpC,iBAAiB,SAA8B;EACpD,MAAM,OAAO,SAAS,eAAe,KAAK;EAC1C,MAAM,WAAW,SAAS,YAAY;EACtC,MAAM,UACH,SAAS,gBAAgB,MACzB,SAAS,iBAAiB,qBAAqB,QAAQ,gBAAgB,KAAK,QAAQ,IAAI;EAG3F,MAAM,UAAU,SAAS,WAAW,MAAM,KAAK,KAAK,iBAA8B,QAAQ,CAAC;EAC3F,IAAI,QAAQ,WAAW,GACrB;EAGF,MAAM,aACJ,SAAS,eACR,SAAS,cAAc,8BAA8B,QAAQ,WAAW,IAAI;EAC/E,MAAM,aACJ,CAAC,cACD,eAAe,KAAK,SAAS,mBAC7B,eAAe,KAAK,SAAS;EAC/B,MAAM,eAAe,aAAa,IAAI,WAAW,sBAAqB,CAAE,CAAC,OAAO;EAChF,MAAM,iBAAiB,aAClB,KAAK,SAAS,aAAa,eAAe,KAAK,SAAS,gBAAgB,eACzE,WAAW,sBAAqB,CAAE,CAAC;EACvC,MAAM,iBAAiB,KAAK,IAC1B,aACA,iBAAiB,KAAK,IAAI,GAAG,SAAS,gBAAgB,CAAC,CAAC;EAI1D,MAAM,mBAAmB,QAAQ,QAAQ,OAAM;GAC7C,MAAM,OAAO,GAAG,sBAAqB;GACrC,OAAO,KAAK,OAAO,cAAc,KAAK,KAAK,UAAU,iBAAiB;EACxE,CAAC;EAED,IAAI,iBAAiB,SAAS,GAK5B,OAHyB,iBAAiB,QAAQ,MAAM,OACtD,GAAG,sBAAqB,CAAE,CAAC,MAAM,KAAK,sBAAqB,CAAE,CAAC,MAAM,KAAK,IAEpE,CAAA,CAAiB;EAI1B,MAAM,YAAY,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,SAAS,uBAAuB,CAAC,CAAC;EAC5E,MAAM,cAAc,eAAe,iBAAiB,eAAe;EACnE,MAAM,gBAAgB,QAAQ,QAAQ,OAAO,GAAG,sBAAqB,CAAE,CAAC,OAAO,cAAc,CAAC;EAE9F,IAAI,cAAc,SAAS,GAKzB,OAHe,cAAc,QAAQ,MAAM,OACzC,GAAG,sBAAqB,CAAE,CAAC,MAAM,KAAK,sBAAqB,CAAE,CAAC,MAAM,KAAK,IAEpE,CAAA,CAAO;EAOhB,OAHc,QAAQ,QAAQ,MAAM,OAClC,GAAG,sBAAqB,CAAE,CAAC,MAAM,KAAK,sBAAqB,CAAE,CAAC,MAAM,KAAK,IAEpE,CAAA,CAAM;CACf;CArFW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAqB,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAArB,OAAA,QAAA,GAAA,sBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;CAAqB,CAAA;;AAArB,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAqB,YAAA,CAAA,EADjC,MAAA,WAAA,CAAA;;ACfD,IAAa,gBAAb,MAAa,cAAa;CACP,WAAW,OAAiB,QAAQ;CACpC,aAAa;CAsB9B,OAAO,QAAgB,SAAuB;EAC5C,MAAM,YAAY,OAAO,WAAW,GAAG,IAAI,OAAO,MAAM,CAAC,IAAI;EAC7D,MAAM,KAAK,KAAK,SAAS,eAAe,SAAS;EAEjD,IAAI,CAAC,IACH,OAAO;EAGT,MAAM,aACJ,SAAS,cACR,KAAK,SAAS,oBACf,KAAK,SAAS;EAChB,MAAM,WAAW,SAAS,YAAY;EACtC,MAAM,mBAAmB,SAAS,oBAAoB;EACtD,MAAM,YAAY,SAAS,aAAa,KAAK;EAE7C,MAAM,OAAO,GAAG,sBAAqB;EACrC,MAAM,aACJ,eAAe,KAAK,SAAS,mBAAmB,eAAe,KAAK,SAAS;EAC/E,MAAM,eAAe,aAAa,IAAI,WAAW,sBAAqB,CAAE,CAAC;EACzE,MAAM,iBAAiB,aACnB,KAAK,SAAS,gBAAgB,eAC9B,WAAW;EACf,MAAM,cACJ,KAAK,OAAO,eAAe,YAAY,KAAK,KAAK,MAAM,eAAe;EACxE,MAAM,iBACJ,KAAK,UAAU,eAAe,kBAAkB,KAAK,SAAS;EAEhE,IACG,oBAAoB,SAAS,eAC7B,oBAAoB,UAAU,eAAe,kBAC7C,oBAAoB,aAAa,eAAe,iBAEjD,OAAO;EAIT,WAAW,SAAS;GAClB,KAAK,WAAW,YAAY,KAAK,MAAM,eAAe;GACtD;EACD,CAAA;EAED,OAAO;CACT;CAnEW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAa,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAAb,OAAA,QAAA,GAAA,sBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAa,YAFZ;CAAM,CAAA;;AAEP,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAa,YAAA,CAAA;EAHzB,MAAA;EAAW,MAAA,CAAA,EACV,YAAY,OACb,CAAA;;;ACED,MAAM,WAAuC;CAC3C,cAAc;CACd,gBAAgB;CAChB,cAAc;CACd,cAAc;CACd,qBAAqB;CACrB,UAAU;EAAE,aAAa;EAAW,aAAa;CAAQ;CACzD,cAAc;CACd,kBAAkB;CAClB,qBAAqB;CACrB,kBAAkB;AACnB;AAED,SAAgB,0BACd,QACA,SAA2B;CAE3B,OAAO;EACL,cAAc,QAAQ,gBAAgB,OAAO;EAC7C,gBAAgB,QAAQ,kBAAkB,OAAO;EACjD,cAAc,QAAQ,gBAAgB,OAAO;EAC7C,cAAc,QAAQ,gBAAgB,OAAO;EAC7C,qBAAqB,QAAQ,uBAAuB,OAAO;EAC3D,UAAU;GACR,aAAa,QAAQ,UAAU,eAAe,OAAO,SAAS;GAC9D,aAAa,QAAQ,UAAU,eAAe,OAAO,SAAS;EAC/D;EACD,cAAc,QAAQ,gBAAgB,OAAO;EAC7C,kBAAkB,QAAQ,oBAAoB,OAAO;EACrD,qBAAqB,QAAQ,uBAAuB,OAAO;EAC3D,kBAAkB,QAAQ,oBAAoB,OAAO;CACtD;AACH;AAEA,MAAa,uBAAuB,IAAI,eACtC,yBACA;CAAE,YAAY;CAAQ,eAAe;AAAQ,CAAE;AAUjD,SAAgB,mBAAmB,UAA8B,CAAA,GAAE;CACjE,OAAO;EACL,SAAS;EACT,kBACE,0BACE,OAAO,sBAAsB;GAAE,UAAU;GAAM,UAAU;EAAI,CAAE,KAAK,UACpE,OAAO;CAEZ;AACH;ACrEA,MAAa,mBAAmB,OAAO,+BAA+B;AAGtE,SAAgB,iBAAiB,QAA8C;CAI7E,IAAI,WAAW,kBAAkB,OAAO;EAAE,OAAO;EAAM,MAAM;CAAM;CACnE,IAAI,OAAgB;CACpB,OAAO,SAAS,IAAI,GAAG,OAAO,KAAI;CAClC,IAAI,SAAS,QAAQ,OAAO,SAAS,UAAU;EAG7C,MAAM,YAAY;EAClB,IAAI,OAAO,UAAU,cAAc,YAAY;GAC7C,IAAI,UAAU,UAAS,GAAI,OAAO;IAAE,OAAO;IAAO,MAAM;GAAS;GACjE,IAAI,WAAW,QAAQ,OAAO,KAAK,UAAU,YAAY;IACvD,MAAM,WAAW;IAEjB,OAAO,SAAS,YAAY,CAAC,SAAS,SAAQ,IAAK,SAAY,SAAS,MAAK;GAC/E,OAAO,IAAI,OAAO,UAAU,SAAS,YACnC,OAAO,UAAU,KAAI;EAEzB;CACF;CACA,OAAO;EAAE,OAAO,SAAS,UAAa,SAAS;EAAM;CAAI;AAC3D;ACbA,MAAM,8BAAc,IAAI,IAAI;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACD,CAAA;AAOD,SAAS,iBAAiB,QAA0B;CAClD,IAAI,CAAC,UAAU,EAAE,kBAAkB,UAAU,OAAO;CACpD,MAAM,UAAU,OAAO,QAAQ,YAAW;CAC1C,IAAI,YAAY,WAAW,YAAY,cAAc,YAAY,UAAU,OAAO;CAElF,MAAM,gBADe,OAAO,QAAQ,mBACd,CAAA,EAAc,aAAa,iBAAiB,CAAC,EAAE,YAAW;CAChF,OAAO,QACJ,OAAuB,qBACxB,OAAO,QAAQ,yBAAyB,KACxC,kBAAkB,MAClB,kBAAkB,UAClB,kBAAkB,gBAAgB;AAEtC;AAyCA,IAAa,uBAAb,MAAa,qBAAoB;CAOtB,eAAe,MACtB,kBAAgB;EAAA,GAAA,YAAA,EAAA,WAAA,eAAA,+BAAA,CAAA;EACd,YAAY,UAAU;CAAK,CAAA;CAItB,gBAAgB,MAA0B,CAAA,GAAG,GAAA,YAAA,CAAA,EAAA,WAAA,gBAAA,CAAA,+BAAA,CAAA,CAAA;CAErC,WAAW,OAAO,oBAAoB;CACtC,UAAU,eACzB,0BAA0B,KAAK,UAAU,KAAK,cAAa,CAAE,GAC9D,GAAA,YAAA,CAAA,EAAA,WAAA,UAAA,CAAA,+BAAA,CAAA,CAAA;CACgB,WAAW,OAAO,QAAQ;CAC1B,OAAO,OAAgC,UAAU,CAAC,CAAC;CACnD,UAAU,OAAO,aAAa;CAC9B,UAAU,OAAO,qBAAqB;CAGtC,WAAW,oBAAmB;CAG9B,gBAAgB,OAAO,cAAc,CAAC,CAAC,SAAS;CAGhD,uBAAuB,OAAO,GAAE,GAAA,YAAA,CAAA,EAAA,WAAA,uBAAA,CAAA,+BAAA,CAAA,CAAA;CAOhC,aAAa,OAAkC,MAAK,GAAA,YAAA,CAAA,EAAA,WAAA,aAAA,CAAA,+BAAA,CAAA,CAAA;CAG5D,eAAe,KAAK,SAAS,WAAU;CAGvC,eAAe,KAAK,qBAAqB,WAAU;CAGnD,SAAS,eAAe,KAAK,aAAY,IAAK,KAAK,QAAO,CAAE,CAAC,cAAa,GAAA,YAAA,CAAA,EAAA,WAAA,SAAA,CAAA,+BAAA,CAAA,CAAA;CAGlE,iBAAiB,OAAO,GAAE,GAAA,YAAA,CAAA,EAAA,WAAA,iBAAA,CAAA,+BAAA,CAAA,CAAA;CAGnC,cAAc;CAMd,kBAAkB;CAElB;CACA;CAER,cAAA;EACE,MAAM,aAAa,OAAO,UAAU;EACpC,sBAAqB;GACnB,KAAK,cAAa;GAClB,KAAK,SAAS,aAAa,iBAAiB,UAAU,KAAK,QAAQ;EACrE,CAAC;EACD,WAAW,gBAAe;GACxB,KAAK,gBAAe;GACpB,KAAK,SAAS,aAAa,oBAAoB,UAAU,KAAK,QAAQ;GACtE,aAAa,KAAK,KAAK;EACzB,CAAC;EAID,mBAAmB,cAAa;GAC9B,MAAM,WAAW,KAAK,QAAO,CAAE,CAAC;GAChC,MAAM,SAAS,WAAW,KAAK,SAAS,cAA2B,QAAQ,IAAI;GAC/E,MAAM,gBAAe;IACnB,MAAM,SAAS,QAAQ,sBAAqB,CAAE,CAAC,UAAU;IACzD,IAAI,WAAW,KAAK,aAAY,GAAI;KAClC,KAAK,qBAAqB,IAAI,MAAM;KACpC,IAAI,KAAK,QAAO,CAAE,CAAC,kBAAkB,KAAK,SAAQ;IACpD;GACF;GACA,UAAU,OAAO;GACjB,IAAI,QAAQ;IACV,MAAM,WAAW,IAAI,eAAe,OAAO;IAC3C,SAAS,QAAQ,MAAM;IACvB,gBAAgB,SAAS,WAAU,CAAE;GACvC;EACF,CAAC;EAID,wBAAuB;GACrB,MAAM,SAAS,KAAK,aAAY;GAEhC,IAAI,CADU,iBAAiB,MAC1B,CAAA,CAAM,OAAO;GAClB,gBAAe;IACb,IAAI,CAAC,KAAK,aAAa;KACrB,KAAK,cAAc;KACnB,IAAI,CAAC,KAAK,iBACR,KAAK,QAAQ,KAAK,eAAe,EAAE,UAAU,KAAK,QAAO,CAAE,CAAC,SAAS,YAAW,CAAE;UAElF,KAAK,WAAW,IAAI,KAAK,uBAAsB,KAAM,IAAI;IAE7D,OAAO,IAAI,WAAW,oBAAoB,KAAK,QAAO,CAAE,CAAC,qBACvD,KAAK,SAAS,KAAK;GAEvB,CAAC;EACH,CAAC;EAID,aAAY;GACV,MAAM,OAAO,KAAK,SAAQ;GAC1B,gBAAe;IACb,KAAK,WAAW,IAAI,IAAI;GAC1B,CAAC;EACH,CAAC;EAID,aAAY;GACV,IAAI,CAAC,KAAK,eAAc,GAAI;GAC5B,MAAM,SAAS,KAAK,uBAAsB;GAC1C,IAAI,QAAQ,KAAK,SAAS,IAAI,MAAM;EACtC,CAAC;CACH;CAaA,SAAS,QAAgB,UAAgC,CAAA,GAAE;EACzD,MAAM,WAAW,KAAK,QAAQ,QAAQ;GACpC,UAAU,KAAK,QAAO,CAAE,CAAC,SAAS;GAClC,kBAAkB,KAAK,QAAO,CAAE,CAAC;GACjC,GAAG;EACJ,CAAA;EACD,IAAI,UAAU;GACZ,MAAM,QAAQ,OAAO,QAAQ,MAAM,EAAE;GACrC,KAAK,WAAW,IAAI,KAAK;GACzB,KAAK,SAAS,IAAI,KAAK;EACzB;EACA,OAAO;CACT;CAUQ,UAAO;EACb,MAAM,SAAS,KAAK,QAAO,CAAE,CAAC;EAC9B,OAAO,MAAM,KAAK,KAAK,KAAK,iBAA8B,MAAM,CAAC,CAAC,CAAC,QAChE,OACC,CAAC,CAAC,GAAG,OACJ,GAAG,aAAa,oBAAoB,KAAK,GAAG,GAAG,WAAW,MAAM,MACjE,GAAG,QAAQ,eAAe,MAAM,KAAK,IAAI;CAE/C;CAOQ,aAAU;EAChB,MAAM,WAAW,iBAAiB,KAAK,IAAI,CAAC,CAAC;EAC7C,MAAM,YACJ,aAAa,UAAU,aAAa,WAChC,KAAK,OACL,8BAA8B,KAAK,IAAI;EAE7C,OAAO,cAAc,KAAK,SAAS,mBAAmB,cAAc,KAAK,SAAS,OAC9E,SACA;CACN;CAMQ,yBAAsB;EAC5B,OAAO,KAAK,QAAQ,iBAAiB;GACnC,aAAa,KAAK;GAClB,SAAS,KAAK,QAAO;GACrB,YAAY,KAAK,WAAU;GAC3B,cAAc,KAAK,OAAM;GACzB,cAAc,KAAK,QAAO,CAAE,CAAC;GAC7B,qBAAqB,KAAK,QAAO,CAAE,CAAC;EACrC,CAAA;CACH;CAKQ,QAAQ,QAAmC,SAA6B;EAC9E,IAAI,CAAC,UAAU,CAAC,KAAK,QAAO,CAAE,CAAC,MAAM,OAAO,GAAG,OAAO,OAAO,QAAQ,MAAM,EAAE,CAAC,GAAG,OAAO;EACxF,OAAO,KAAK,QAAQ,OAAO,QAAQ;GACjC,YAAY,KAAK,WAAU;GAC3B,WAAW,KAAK,OAAM;GACtB,GAAG;EACJ,CAAA;CACH;CAWQ,SAAS,mBAAsD,QAAM;EAC3E,IAAI,KAAK,aACP,KAAK,QAAQ,KAAK,WAAU,GAAI;GAAE,UAAU;GAAW;EAAgB,CAAE;CAE7E;CAMQ,gBAAa;EACnB,MAAM,SAAS,KAAK,WAAU,KAAM,KAAK;EACzC,IAAI,WAAW,KAAK,QAAQ;GAC1B,KAAK,gBAAe;GACpB,KAAK,SAAS;GACd,KAAK,gBAAgB,MAAM;EAC7B;CACF;CAKQ,gBAAgB,QAAmB;EACzC,OAAO,iBAAiB,UAAU,KAAK,QAAQ;EAC/C,OAAO,iBAAiB,SAAS,KAAK,cAAc,EAAE,SAAS,KAAI,CAAE;EACrE,OAAO,iBAAiB,cAAc,KAAK,cAAc,EAAE,SAAS,KAAI,CAAE;EAC1E,OAAO,iBAAiB,aAAa,KAAK,cAAc,EAAE,SAAS,KAAI,CAAE;EACzE,OAAO,iBAAiB,WAAW,KAAK,SAAS;EACjD,KAAK,SAAS,iBAAiB,WAAW,KAAK,SAAS;CAC1D;CAKQ,kBAAe;EACrB,IAAI,CAAC,KAAK,QAAQ;EAClB,KAAK,OAAO,oBAAoB,UAAU,KAAK,QAAQ;EACvD,KAAK,OAAO,oBAAoB,SAAS,KAAK,YAAY;EAC1D,KAAK,OAAO,oBAAoB,cAAc,KAAK,YAAY;EAC/D,KAAK,OAAO,oBAAoB,aAAa,KAAK,YAAY;EAC9D,KAAK,OAAO,oBAAoB,WAAW,KAAK,SAAS;EACzD,KAAK,SAAS,oBAAoB,WAAW,KAAK,SAAS;CAC7D;CAMiB,qBAAoB;EACnC,KAAK,kBAAkB;CACzB;CAOiB,aAAa,UAAgB;EAC5C,IAAI,EAAE,iBAAiB,gBAAgB;EACvC,IAAI,iBAAiB,MAAM,MAAM,GAAG;EACpC,IAAI,CAAC,YAAY,IAAI,MAAM,GAAG,GAAG;EACjC,MAAM,WAAW,MAAM;EACvB,MAAM,aAAa,KAAK,WAAU;EAMlC,IAJE,CAAC,YACD,aAAa,KAAK,SAAS,QAC3B,aAAa,KAAK,SAAS,oBAC1B,aAAa,eAAe,YAAY,WAAW,SAAS,QAAQ,IAAI,OAEzE,KAAK,kBAAkB;CAE3B;CAMiB,iBAAgB;EAC/B,IAAI,CAAC,KAAK,aAAa;EACvB,MAAM,SAAS,KAAK,uBAAsB;EAC1C,IAAI,QAAQ,KAAK,WAAW,IAAI,MAAM;EACtC,aAAa,KAAK,KAAK;EACvB,KAAK,QAAQ,iBACL,KAAK,eAAe,QAAQ,aAAa,WAAW,CAAC,GAC3D,KAAK,QAAO,CAAE,CAAC,YAAY;CAE/B;CAKiB,iBAAgB;EAC/B,KAAK,cAAa;EAClB,IAAI,KAAK,QAAO,CAAE,CAAC,kBAAkB,KAAK,SAAQ;CACpD;CApUW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAoB,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAApB,OAAA,OAAA,GAAA,qBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,MAAA;EAAoB,cAAA;EAAA,UAAA;EAAA,QAAA;GAAA,cAAA;IAAA,mBAAA;IAAA,YAAA;IAAA,UAAA;IAAA,YAAA;IAAA,mBAAA;GAAA;GAAA,eAAA;IAAA,mBAAA;IAAA,YAAA;IAAA,UAAA;IAAA,YAAA;IAAA,mBAAA;GAAA;EAAA;EAAA,MAAA,EAAA,YAAA,EAAA,oBAAA,OAAA,EAAA;EAAA,WAHpB,CAAC,qBAAqB;EAAC,UAAA,CAAA,aAAA;EAAA,UAAA;CAAA,CAAA;;AAGvB,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAoB,YAAA,CAAA;EANhC,MAAA;EAAU,MAAA,CAAA;GACT,UAAU;GACV,UAAU;GACV,WAAW,CAAC,qBAAqB;GACjC,MAAM,EAAE,sBAAsB,OAAI;EACnC,CAAA;;;;;;;;;;;;;;;;;;;;;;ACpGD,IAAa,wBAAb,MAAa,sBAAqB;CACvB,eAAe,MAAM,IAAG,GAAA,YAAA,CAAA,EAAA,WAAA,eAAA,CAAA,+BAAA,CAAA,CAAA;CAChB,OAAO,OAAgC,UAAU;CAClE,cAAA;EACE,aAAY;GACV,MAAM,KAAK,KAAK,aAAY;GAC5B,IAAI,IAAI,KAAK,KAAK,cAAc,KAAK;EACvC,CAAC;CACH;CARW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAqB,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAArB,OAAA,OAAA,GAAA,qBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,MAAA;EAAqB,cAAA;EAAA,UAAA;EAAA,QAAA,EAAA,cAAA;GAAA,mBAAA;GAAA,YAAA;GAAA,UAAA;GAAA,YAAA;GAAA,mBAAA;EAAA,EAAA;EAAA,MAAA,EAAA,YAAA,EAAA,2BAAA,OAAA,EAAA;EAAA,UAAA;CAAA,CAAA;;AAArB,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAqB,YAAA,CAAA;EAJjC,MAAA;EAAU,MAAA,CAAA;GACT,UAAU;GACV,MAAM,EAAE,6BAA6B,OAAI;EAC1C,CAAA"}
1
+ {"version":3,"file":"devzwo-ngx-magic-scroll.mjs","names":[],"sources":["../../../projects/ngx-magic-scroll/src/lib/navigation/linked-route-fragment.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/inject-scrollable-parent.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/inject-header-size.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/nearest-anchor.provider.ts","../../../projects/ngx-magic-scroll/src/lib/navigation/scroll-service.ts","../../../projects/ngx-magic-scroll/src/lib/facade/magic-scroll-options.ts","../../../projects/ngx-magic-scroll/src/lib/facade/scroll-source.ts","../../../projects/ngx-magic-scroll/src/lib/facade/magic-scroll.directive.ts","../../../projects/ngx-magic-scroll/src/lib/facade/scroll-anchor.directive.ts"],"sourcesContent":["import { effect, inject, linkedSignal, WritableSignal } from '@angular/core';\nimport { toSignal } from '@angular/core/rxjs-interop';\nimport { ActivatedRoute, Router } from '@angular/router';\n\n/**\n * Key used to tag internal library-initiated scroll-spy navigation events.\n */\nexport const MAGIC_SCROLL_ORIGIN_KEY = '__ngx_magic_scroll_origin';\n\n/**\n * Value identifying an internal scroll-spy navigation origin.\n */\nexport const MAGIC_SCROLL_ORIGIN_SCROLL_SPY = 'scroll-spy';\n\n/**\n * Creates an Angular signal synchronized bidirectionally with the URL fragment (#fragment).\n *\n * Reads the current route fragment as a writable signal (WritableSignal).\n * - Reading reacts to URL fragment changes through ActivatedRoute.fragment.\n * - Writing, for example fragmentSignal.set('anchor-id'), asynchronously updates the URL\n * through Router.navigateByUrl with replaceUrl: true and origin tagging. This preserves query parameters\n * and avoids adding a history entry for each scroll update while distinguishing scroll-spy updates from\n * external user navigations.\n *\n * @returns A WritableSignal<string | null | undefined> linked to the URL fragment.\n * @example\n * ```ts\n * @Component({ ... })\n * export class MyViewComponent {\n * // Initialize the signal\n * protected readonly routeFragment = linkedRouteFragment();\n *\n * onAnchorReached(anchorId: string) {\n * // Update the URL fragment without adding history entries (#anchorId)\n * this.routeFragment.set(anchorId);\n * }\n * }\n * ```\n */\nexport function linkedRouteFragment(): WritableSignal<string | null | undefined> {\n const route = inject(ActivatedRoute);\n const router = inject(Router);\n const fragment = toSignal(route.fragment);\n\n const actualFragment = linkedSignal(() => fragment());\n\n effect(() => {\n const newFragment = actualFragment();\n const currentRouteFragment = fragment();\n if (currentRouteFragment !== newFragment) {\n const tree = router.createUrlTree([], {\n relativeTo: route,\n fragment: newFragment ?? undefined,\n queryParamsHandling: 'preserve',\n });\n\n router\n .navigateByUrl(tree, {\n replaceUrl: true,\n state: {\n ...history.state,\n [MAGIC_SCROLL_ORIGIN_KEY]: MAGIC_SCROLL_ORIGIN_SCROLL_SPY,\n },\n info: { [MAGIC_SCROLL_ORIGIN_KEY]: MAGIC_SCROLL_ORIGIN_SCROLL_SPY },\n })\n .catch((error: Error) => {\n console.log(`Cant set fragment ${newFragment}`, error);\n });\n }\n });\n\n return actualFragment;\n}\n","import { ElementRef, inject } from '@angular/core';\n\n/**\n * Finds the closest scrollable ancestor in the DOM.\n *\n * Walks up the DOM tree looking for overflow-y: auto or overflow-y: scroll.\n * Call within an Angular injection context to start at ElementRef.nativeElement,\n * or pass a hostElement explicitly.\n *\n * @param hostElement - Optional starting element. An explicit element takes precedence;\n * otherwise the starting element is obtained by injecting ElementRef<HTMLElement>.\n * @returns The closest scrollable HTMLElement, or undefined if none exists.\n * @example\n * ```ts\n * // Within an injection context (for example a directive or component constructor):\n * const scrollParent = injectScrollableParentElement();\n *\n * // Outside an injection context:\n * const scrollParent = injectScrollableParentElement(myHtmlElement);\n * ```\n */\nexport function injectScrollableParentElement(\n hostElement?: HTMLElement | null,\n): HTMLElement | undefined {\n function findScrollableParent(el: HTMLElement): HTMLElement | undefined {\n let current = el.parentElement ?? undefined;\n while (current) {\n const overflowY = getComputedStyle(current).overflowY;\n if (\n overflowY === 'auto' ||\n overflowY === 'scroll' /**&& current.scrollHeight > current.clientHeight **/\n ) {\n return current;\n }\n current = current.parentElement ?? undefined;\n }\n return undefined;\n }\n\n return findScrollableParent(\n hostElement ?? (inject(ElementRef<HTMLElement>).nativeElement as HTMLElement),\n );\n}\n","/**\n * Measures the height of a header selected within the supplied document.\n * Used to calculate scroll and anchor offsets so a fixed or sticky header does not\n * cover the scrolled content.\n *\n * @param headerSelector - Header CSS selector, such as '.header' or 'app-header'.\n * @param document - Document instance in which to search, for example injected through DOCUMENT.\n * @returns The absolute header height in pixels, or 0 if the element is not found.\n * @example\n * ```ts\n * const document = inject(DOCUMENT);\n * const height = injectedHeaderHeight('.header', document);\n * ```\n */\nexport function injectedHeaderHeight(headerSelector: string, document: Document): number {\n const boundingBox = document.querySelector<HTMLElement>(headerSelector)?.getBoundingClientRect();\n if (!boundingBox) {\n console.warn('No boundingBox found for headerSelector: ', headerSelector);\n return 0;\n }\n\n return Math.abs(boundingBox.height);\n}\n","import { DOCUMENT } from '@angular/common';\nimport { inject, Injectable } from '@angular/core';\nimport { injectScrollableParentElement } from './inject-scrollable-parent';\nimport { injectedHeaderHeight } from './inject-header-size';\n\n/**\n * Options for finding the nearest anchor.\n */\nexport interface NearestAnchorOptions {\n /** Explicit scroll container, including a host that is itself scrollable. */\n scrollable?: HTMLElement;\n /** Preselected anchor elements (e.g. scoped, explicit anchors in the facade). */\n anchors?: readonly HTMLElement[];\n /**\n * Optional host element within which to find anchor elements.\n * Defaults to searching the entire Document.\n */\n hostElement?: HTMLElement;\n /**\n * CSS selector for eligible anchors, for example '[id^=\"item-card-\"]'.\n * Defaults to '[id]'.\n */\n selector?: string;\n /**\n * Static top offset, in pixels, such as a fixed gap or padding.\n */\n headerOffset?: number;\n /**\n * CSS selector for a fixed or sticky header, for example '.header'.\n * Its measured height is added to the offset so covered elements are not selected\n * as the topmost visible anchor.\n */\n headerSelector?: string;\n /**\n * Static bottom offset, in pixels, such as a sticky footer gap or bottom margin.\n */\n bottomOffset?: number;\n /**\n * Relative activation threshold (0.0 to 1.0) defining the trigger line within the visible reading area.\n */\n activationThreshold?: number;\n}\n\n/**\n * Finds the anchor representing the currently read section based on full containment and activation boundaries.\n *\n * Supports scroll-spy behavior to identify the current section while scrolling and\n * synchronize the URL fragment. A section is activated immediately when fully visible in the\n * viewport below sticky headers, or remains active while scrolling through its content until\n * the next section crosses the activation boundary or becomes fully visible.\n *\n * @remarks\n * This service does not use providedIn: 'root'. Register it in application or component\n * providers when using it internally. The facade provides its own instance.\n */\n@Injectable()\nexport class NearestAnchorProvider {\n private readonly document = inject(DOCUMENT);\n\n /**\n * Calculates the ID of the active anchor based on scroll position and visible area boundaries.\n *\n * 1. Evaluates all eligible anchors within the host or document.\n * 2. Determines the visible reading area (`topBoundary = scrollableTop + headerOffset + headerHeight`, `bottomBoundary = (containerBottom | viewportHeight) - bottomOffset`).\n * 3. Prioritizes fully contained anchors (`top >= topBoundary - 1` and `bottom <= bottomBoundary + 1`), selecting the topmost one.\n * 4. Falls back to the anchor that crossed `triggerLine` most recently (`top <= triggerLine + 1`).\n * 5. Falls back to the first anchor if no anchor has crossed the boundary yet.\n *\n * @param options - Host element, selector, scroll container and header-offset settings.\n * @returns The active anchor's element ID, or undefined if no eligible element exists.\n * @example\n * ```ts\n * const nearestId = nearestAnchorProvider.getNearestAnchor({\n * hostElement: listContainer,\n * selector: '[id^=\"card-\"]',\n * headerSelector: '.app-header',\n * bottomOffset: 40\n * });\n * ```\n */\n public getNearestAnchor(options?: NearestAnchorOptions): string | undefined {\n const host = options?.hostElement ?? this.document;\n const selector = options?.selector ?? '[id]';\n const offset =\n (options?.headerOffset ?? 0) +\n (options?.headerSelector ? injectedHeaderHeight(options.headerSelector, this.document) : 0);\n\n // Collects all HTML elements with an id – these are our potential anchor targets\n const anchors = options?.anchors ?? Array.from(host.querySelectorAll<HTMLElement>(selector));\n if (anchors.length === 0) {\n return undefined;\n }\n\n const scrollable =\n options?.scrollable ??\n (options?.hostElement ? injectScrollableParentElement(options.hostElement) : undefined);\n const isDocument =\n !scrollable ||\n scrollable === this.document.documentElement ||\n scrollable === this.document.body;\n const topBoundary = (isDocument ? 0 : scrollable.getBoundingClientRect().top) + offset;\n const viewportBottom = isDocument\n ? (this.document.defaultView?.innerHeight ?? this.document.documentElement.clientHeight)\n : scrollable.getBoundingClientRect().bottom;\n const bottomBoundary = Math.max(\n topBoundary,\n viewportBottom - Math.max(0, options?.bottomOffset ?? 0),\n );\n\n // 1. Check for fully contained anchors (entire element inside visible reading area)\n const containedAnchors = anchors.filter((el) => {\n const rect = el.getBoundingClientRect();\n return rect.top >= topBoundary - 1 && rect.bottom <= bottomBoundary + 1;\n });\n\n if (containedAnchors.length > 0) {\n // Topmost fully visible anchor\n const topmostContained = containedAnchors.reduce((best, el) =>\n el.getBoundingClientRect().top < best.getBoundingClientRect().top ? el : best,\n );\n return topmostContained.id;\n }\n\n // 2. Reading line fallback: Anchor that crossed triggerLine most recently\n const threshold = Math.max(0, Math.min(1, options?.activationThreshold ?? 0));\n const triggerLine = topBoundary + (bottomBoundary - topBoundary) * threshold;\n const passedAnchors = anchors.filter((el) => el.getBoundingClientRect().top <= triggerLine + 1);\n\n if (passedAnchors.length > 0) {\n // Pick the anchor that crossed the trigger line most recently (highest top <= triggerLine)\n const active = passedAnchors.reduce((best, el) =>\n el.getBoundingClientRect().top > best.getBoundingClientRect().top ? el : best,\n );\n return active.id;\n }\n\n // 3. Before any anchor has reached the boundary, select the first anchor\n const first = anchors.reduce((best, el) =>\n el.getBoundingClientRect().top < best.getBoundingClientRect().top ? el : best,\n );\n return first.id;\n }\n}\n","import { inject, Injectable } from '@angular/core';\nimport { DOCUMENT } from '@angular/common';\n\n/**\n * Options for a scroll operation performed by ScrollService.\n */\nexport interface ScrollOptions {\n /**\n * Scrollable container. Defaults to the document's scrolling element.\n */\n scrollable?: HTMLElement;\n /**\n * Scroll behavior: 'instant' for an immediate jump, 'smooth' for animation,\n * or 'auto' to follow the browser's CSS scroll behavior. Defaults to 'instant'.\n */\n behavior?: ScrollBehavior;\n /**\n * Visibility condition under which scrolling is skipped:\n * - 'none': Always scroll (default).\n * - 'top': Skip when the element's top edge is visible.\n * - 'full': Skip when the entire element is visible.\n * - 'always': Skip when either the top or bottom edge is visible.\n */\n ignoreWhenInView?: 'none' | 'top' | 'full' | 'always';\n /**\n * Additional top offset for the target, in pixels, for example for a header or padding.\n * Defaults to 20 pixels.\n */\n topOffset?: number;\n}\n\n/**\n * Central service for scrolling to DOM elements and anchors.\n *\n * Supports explicit scroll containers or document scrolling, configurable header offsets,\n * visibility checks to avoid unnecessary movement, and smooth or instant scrolling.\n */\n@Injectable({\n providedIn: 'root',\n})\nexport class ScrollService {\n private readonly document = inject<Document>(DOCUMENT);\n private readonly TOP_OFFSET = 20;\n\n /**\n * Scrolls to the element identified by its ID.\n *\n * @param anchor - Target HTML element ID, with or without a leading #.\n * @param options - Scroll container, behavior, offset and visibility settings.\n * @returns True when the target exists and scrolling is initiated; otherwise false.\n * @example\n * ```ts\n * // Simple scrolling\n * scrollService.scroll('section-details');\n *\n * // With options\n * scrollService.scroll('card-123', {\n * scrollable: scrollContainerElement,\n * behavior: 'smooth',\n * topOffset: 80,\n * ignoreWhenInView: 'top'\n * });\n * ```\n */\n scroll(anchor: string, options?: ScrollOptions): boolean {\n const elementId = anchor.startsWith('#') ? anchor.slice(1) : anchor;\n const el = this.document.getElementById(elementId);\n\n if (!el) {\n return false;\n }\n\n const scrollable =\n options?.scrollable ??\n (this.document.scrollingElement as HTMLElement | null) ??\n this.document.documentElement;\n const behavior = options?.behavior ?? 'instant';\n const ignoreWhenInView = options?.ignoreWhenInView ?? 'none';\n const topOffset = options?.topOffset ?? this.TOP_OFFSET;\n\n const rect = el.getBoundingClientRect();\n const isDocument =\n scrollable === this.document.documentElement || scrollable === this.document.body;\n const containerTop = isDocument ? 0 : scrollable.getBoundingClientRect().top;\n const viewportHeight = isDocument\n ? this.document.documentElement.clientHeight\n : scrollable.clientHeight;\n const isTopInView =\n rect.top >= containerTop + topOffset - 1 && rect.top < containerTop + viewportHeight;\n const isBottomInView =\n rect.bottom <= containerTop + viewportHeight && rect.bottom > containerTop;\n\n if (\n (ignoreWhenInView == 'top' && isTopInView) ||\n (ignoreWhenInView == 'full' && isTopInView && isBottomInView) ||\n (ignoreWhenInView == 'always' && (isTopInView || isBottomInView))\n ) {\n return false;\n }\n\n // One operation keeps offsets accurate during smooth scrolling and only moves the selected container.\n scrollable.scrollTo({\n top: scrollable.scrollTop + rect.top - containerTop - topOffset,\n behavior,\n });\n\n return true;\n }\n}\n","import { inject, InjectionToken, Provider } from '@angular/core';\nimport { ScrollOptions } from '../navigation/scroll-service';\n\n/**\n * Anchor selection strategy for a `magicScroll` region.\n * - `'all'`: Default strategy. Descendant elements matching any configured `anchorPrefixes`\n * (or all non-empty IDs if no prefix is configured) as well as explicit `scrollAnchor` markers participate.\n * - `'explicit'`: Strict explicit-only mode. Only elements marked with `[scrollAnchor]` or `scrollAnchor`\n * participate, regardless of their element ID or configured prefixes.\n */\nexport type AnchorSelectionMode = 'all' | 'explicit';\n\n/** Defaults for an application or overrides for one `magicScroll` region. */\nexport interface MagicScrollOptions {\n /**\n * Allowed anchor ID prefixes. Descendant elements matching any prefix participate.\n * Explicit `scrollAnchor` markers also participate.\n * Passing an empty array `[]` disables prefix matching so only explicit markers participate.\n */\n anchorPrefixes?: readonly string[] | string[];\n /**\n * Selection strategy for anchor discovery.\n * - `'all'`: Selects elements matching prefixes or explicit markers (default).\n * - `'explicit'`: Selects only elements with explicit `scrollAnchor` markers.\n */\n anchorSelection?: AnchorSelectionMode;\n /** Selector for a fixed/sticky header whose height is measured automatically. */\n headerSelector?: string;\n /** Gap below the header, or the complete top offset when no header is selected. */\n headerOffset?: number;\n /** Bottom offset in pixels to adjust the active reading boundary from the bottom. */\n bottomOffset?: number;\n /** Relative activation threshold (0.0 to 1.0) defining the trigger line within the visible reading area. */\n activationThreshold?: number;\n /** Restoration and deliberate user navigation have separate defaults. */\n behavior?: {\n restoration?: ScrollBehavior;\n interaction?: ScrollBehavior;\n };\n debounceTime?: number;\n ignoreWhenInView?: ScrollOptions['ignoreWhenInView'];\n /** Keep the current anchor visible after later data updates. Defaults to true. */\n restoreOnDataChange?: boolean;\n /** Preserve the current anchor on window resize and measured header-height changes. */\n preserveOnResize?: boolean;\n}\n\nexport interface ResolvedMagicScrollOptions {\n anchorPrefixes: readonly string[];\n anchorSelection: AnchorSelectionMode;\n headerSelector: string;\n headerOffset: number;\n bottomOffset: number;\n activationThreshold: number;\n behavior: { restoration: ScrollBehavior; interaction: ScrollBehavior };\n debounceTime: number;\n ignoreWhenInView: NonNullable<ScrollOptions['ignoreWhenInView']>;\n restoreOnDataChange: boolean;\n preserveOnResize: boolean;\n}\n\nconst defaults: ResolvedMagicScrollOptions = {\n anchorPrefixes: [''],\n anchorSelection: 'all',\n headerSelector: '',\n headerOffset: 0,\n bottomOffset: 0,\n activationThreshold: 0,\n behavior: { restoration: 'instant', interaction: 'smooth' },\n debounceTime: 500,\n ignoreWhenInView: 'none',\n restoreOnDataChange: true,\n preserveOnResize: true,\n};\n\nexport function resolveMagicScrollOptions(\n parent: ResolvedMagicScrollOptions,\n options: MagicScrollOptions,\n): ResolvedMagicScrollOptions {\n return {\n anchorPrefixes: options.anchorPrefixes ?? parent.anchorPrefixes,\n anchorSelection: options.anchorSelection ?? parent.anchorSelection,\n headerSelector: options.headerSelector ?? parent.headerSelector,\n headerOffset: options.headerOffset ?? parent.headerOffset,\n bottomOffset: options.bottomOffset ?? parent.bottomOffset,\n activationThreshold: options.activationThreshold ?? parent.activationThreshold,\n behavior: {\n restoration: options.behavior?.restoration ?? parent.behavior.restoration,\n interaction: options.behavior?.interaction ?? parent.behavior.interaction,\n },\n debounceTime: options.debounceTime ?? parent.debounceTime,\n ignoreWhenInView: options.ignoreWhenInView ?? parent.ignoreWhenInView,\n restoreOnDataChange: options.restoreOnDataChange ?? parent.restoreOnDataChange,\n preserveOnResize: options.preserveOnResize ?? parent.preserveOnResize,\n };\n}\n\nexport const MAGIC_SCROLL_OPTIONS = new InjectionToken<ResolvedMagicScrollOptions>(\n 'Magic scroll defaults',\n { providedIn: 'root', factory: () => defaults },\n);\n\n/**\n * Register defaults globally or on a component. Child providers inherit unspecified options.\n * Per-region `scrollOptions` and per-call `scrollTo` options take precedence.\n *\n * @example\n * provideMagicScroll({ anchorPrefixes: ['project-'], behavior: { interaction: 'smooth' } })\n */\nexport function provideMagicScroll(options: MagicScrollOptions = {}): Provider {\n return {\n provide: MAGIC_SCROLL_OPTIONS,\n useFactory: () =>\n resolveMagicScrollOptions(\n inject(MAGIC_SCROLL_OPTIONS, { skipSelf: true, optional: true }) ?? defaults,\n options,\n ),\n };\n}\n","import { isSignal, Signal } from '@angular/core';\nimport { ScrollDataSource } from '../navigation/scroll-data-source';\n\n/** Structural Angular resource contract; no dependency on a data-fetching provider. */\nexport interface ScrollResource {\n readonly value: () => unknown;\n readonly isLoading: () => boolean;\n readonly hasValue?: () => boolean;\n}\n\n/**\n * Pass a signal (also computed/input/model signals), a resource/rxResource, or\n * reactive data()/isLoading() getters. Plain input values are supported too.\n * For signal forms pass the field's value signal, e.g. `form().value`.\n * Supabase data and loading signals can use the neutral ScrollDataSource contract.\n */\nexport type ScrollSource =\n | Signal<unknown>\n | ScrollResource\n | ScrollDataSource\n | object\n | string\n | number\n | boolean\n | null\n | undefined;\n\nexport const NO_SCROLL_SOURCE = Symbol('Static, already rendered data');\n\n/** Internal readiness adapter. null/undefined wait; [], 0, false and '' are ready. */\nexport function readScrollSource(source: ScrollSource | typeof NO_SCROLL_SOURCE): {\n ready: boolean;\n data: unknown;\n} {\n if (source === NO_SCROLL_SOURCE) return { ready: true, data: source };\n let data: unknown = source;\n while (isSignal(data)) data = data();\n if (data !== null && typeof data === 'object') {\n // Query results may be proxies whose `has` trap reflects the current value.\n // Read the loading getter directly: `isLoading in source` can be false when idle.\n const candidate = data as { isLoading?: unknown; data?: unknown };\n if (typeof candidate.isLoading === 'function') {\n if (candidate.isLoading()) return { ready: false, data: undefined };\n if ('value' in data && typeof data.value === 'function') {\n const resource = data as ScrollResource;\n // Reading an Angular resource's value in an error state can throw.\n data = resource.hasValue && !resource.hasValue() ? undefined : resource.value();\n } else if (typeof candidate.data === 'function') {\n data = candidate.data();\n }\n }\n }\n return { ready: data !== undefined && data !== null, data };\n}\n","import { DOCUMENT } from '@angular/common';\nimport {\n afterNextRender,\n afterRenderEffect,\n computed,\n DestroyRef,\n Directive,\n effect,\n ElementRef,\n inject,\n input,\n signal,\n untracked,\n} from '@angular/core';\nimport { ActivatedRoute, Navigation, Router } from '@angular/router';\nimport {\n linkedRouteFragment,\n MAGIC_SCROLL_ORIGIN_KEY,\n MAGIC_SCROLL_ORIGIN_SCROLL_SPY,\n} from '../navigation/linked-route-fragment';\nimport { injectScrollableParentElement } from '../navigation/inject-scrollable-parent';\nimport { NearestAnchorProvider } from '../navigation/nearest-anchor.provider';\nimport { ScrollOptions, ScrollService } from '../navigation/scroll-service';\nimport {\n MAGIC_SCROLL_OPTIONS,\n MagicScrollOptions,\n resolveMagicScrollOptions,\n} from './magic-scroll-options';\nimport { NO_SCROLL_SOURCE, readScrollSource, ScrollSource } from './scroll-source';\n\n/**\n * Per-action scroll options for programmatic navigation via {@link MagicScrollDirective.scrollTo}.\n * Top offsets and container scoping are resolved automatically by the directive facade.\n */\nexport type MagicScrollToOptions = Pick<\n ScrollOptions,\n 'behavior' | 'topOffset' | 'ignoreWhenInView'\n>;\n\n/**\n * Standard keyboard navigation keys that manipulate viewport scroll position.\n * Used by {@link MagicScrollDirective} to detect manual user scroll intent and cancel\n * pending automatic initial restorations prior to asynchronous data loading readiness.\n */\nconst SCROLL_KEYS = new Set([\n 'ArrowUp',\n 'ArrowDown',\n 'PageUp',\n 'PageDown',\n 'Home',\n 'End',\n ' ',\n 'Spacebar',\n 'Space',\n]);\n\n/**\n * Determines whether a keyboard event target is an interactive or editable text control.\n * Ensures typing and navigating within input fields, textareas, dropdowns, and contenteditable\n * areas do not mistakenly cancel pending initial scroll restoration.\n */\nfunction isEditableTarget(target: EventTarget | null): boolean {\n if (!target || !(target instanceof Element)) return false;\n const tagName = target.tagName.toLowerCase();\n if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') return true;\n const editableHost = target.closest('[contenteditable]');\n const editableValue = editableHost?.getAttribute('contenteditable')?.toLowerCase();\n return Boolean(\n (target as HTMLElement).isContentEditable ||\n target.closest('input, textarea, select') ||\n editableValue === '' ||\n editableValue === 'true' ||\n editableValue === 'plaintext-only',\n );\n}\n\n/**\n * Complete facade directive coordinating anchor scrolling, two-tier route synchronization,\n * user-intent cancellation, data readiness restoration, and layout/data position preservation.\n *\n * @remarks\n * ### Architectural Pillars:\n * 1. **Two-Tier Anchor State Model**:\n * - `liveAnchor`: Immediate per-scroll evaluation representing the visible section currently in view.\n * Used as the source of truth for immediate data and layout realignments.\n * - `fragment` / `activeAnchor`: Debounced (default 500ms) URL router synchronization preventing browser\n * history thrashing during continuous scrolling.\n * 2. **User Intent Detection & Cancellation**:\n * - Captures manual user gestures (`wheel`, `touchstart`, `touchmove`, navigation `keydown`) occurring prior\n * to asynchronous `scrollSource` readiness.\n * - Invalidates pending delayed initial restorations so conscious user navigation is never overridden.\n * - Safely excludes editable form controls (`<input>`, `<textarea>`, `contenteditable`).\n * 3. **Reactive Render Pipeline & Lifecycle Coordination**:\n * - Sequenced `afterRenderEffect` execution blocks guarantee sticky header measurement and `ResizeObserver`\n * monitoring are active before initial fragment restoration and data preservation execute.\n * 4. **Preservation & Realignment Engine**:\n * - Automatic realignments (`preserve('top')`) on data updates and container/header resizes based on the live active section.\n * 5. **Container & Anchor Scoping**:\n * - Automatic scroll container resolution (window vs scrollable element) and hierarchical anchor ownership isolation.\n *\n * @example\n * ```html\n * <div magicScroll #scroll=\"magicScroll\" [scrollSource]=\"projects\">\n * <section id=\"project-1\">...</section>\n * <section id=\"project-2\">...</section>\n * </div>\n * <button (click)=\"scroll.scrollTo('project-2')\">Go to Project 2</button>\n * ```\n */\n@Directive({\n selector: '[magicScroll]',\n exportAs: 'magicScroll',\n providers: [NearestAnchorProvider],\n host: { '[attr.magicScroll]': '\"\"' },\n})\nexport class MagicScrollDirective {\n /**\n * Asynchronous data source or signal determining scroll readiness.\n * - When omitted (`NO_SCROLL_SOURCE`), initial restoration executes on the initial rendered DOM.\n * - When provided, initial restoration waits until `readScrollSource(source).ready === true`.\n * Subsequent ready data updates trigger automatic position preservation when `restoreOnDataChange` is enabled.\n */\n readonly scrollSource = input<ScrollSource | typeof NO_SCROLL_SOURCE, ScrollSource>(\n NO_SCROLL_SOURCE,\n { transform: (value) => value },\n );\n\n /** Configuration overrides merged with global `provideMagicScroll` options. */\n readonly scrollOptions = input<MagicScrollOptions>({});\n\n private readonly defaults = inject(MAGIC_SCROLL_OPTIONS);\n private readonly options = computed(() =>\n resolveMagicScrollOptions(this.defaults, this.scrollOptions()),\n );\n private readonly document = inject(DOCUMENT);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;\n private readonly service = inject(ScrollService);\n private readonly nearest = inject(NearestAnchorProvider);\n private readonly router = inject(Router, { optional: true });\n\n /** Bidirectional route fragment synchronization signal (debounced URL writer and listener). */\n private readonly fragment = linkedRouteFragment();\n\n /** Initial route URL fragment snapshot captured upon directive instantiation. */\n private readonly initialAnchor = inject(ActivatedRoute).snapshot.fragment;\n\n /** Dynamically measured header height via ResizeObserver. */\n private readonly measuredHeaderHeight = signal(0);\n\n /**\n * Real-time visible section identifier updated per scroll event.\n * Serves as the source of truth for immediate data and layout realignments (`preserve`),\n * decoupling preservation from the debounced URL fragment signal.\n */\n private readonly liveAnchor = signal<string | null | undefined>(null);\n\n /** Current URL anchor, suitable for active-navigation highlighting. */\n readonly activeAnchor = this.fragment.asReadonly();\n\n /** Measured header height, useful for sticky companion panels. */\n readonly headerHeight = this.measuredHeaderHeight.asReadonly();\n\n /** Complete effective top offset, including header height and configured gap. */\n readonly offset = computed(() => this.headerHeight() + this.options().headerOffset);\n\n /** Monotonically increasing revision counter triggering debounced URL fragment updates. */\n private readonly scrollRevision = signal(0);\n\n /** Flag indicating whether the initial restoration phase has been executed. */\n private initialized = false;\n\n /**\n * Flag indicating whether manual user interaction (wheel, touch, or navigation keys) occurred\n * before asynchronous data loading became ready. When true, delayed restoration is skipped.\n */\n private userInterrupted = false;\n\n /** Pending destination anchor awaiting data readiness or DOM rendering. */\n private pendingDestination: string | null = this.initialAnchor ?? null;\n\n /** Tracks the last fragment processed from route fragment synchronization. */\n private lastProcessedFragment: string | null | undefined = undefined;\n\n private target?: EventTarget;\n private timer?: ReturnType<typeof setTimeout>;\n /** Active DOM mutation observer used to retry pending scroll destinations once target anchor elements are inserted. */\n private retryObserver?: MutationObserver;\n /** Timeout timer bounding the lifetime of destination retries to prevent memory leaks from non-existent anchors. */\n private retryTimeoutTimer?: ReturnType<typeof setTimeout>;\n\n constructor() {\n const destroyRef = inject(DestroyRef);\n afterNextRender(() => {\n this.refreshTarget();\n this.document.defaultView?.addEventListener('resize', this.onResize);\n });\n destroyRef.onDestroy(() => {\n this.detachListeners();\n this.document.defaultView?.removeEventListener('resize', this.onResize);\n clearTimeout(this.timer);\n this.cancelDestinationRetry();\n this.pendingDestination = null;\n });\n\n // Phase 1: Header measurement & ResizeObserver tracking.\n // Sequenced first so accurate header dimensions and offsets are available before initial restoration.\n afterRenderEffect((onCleanup) => {\n const selector = this.options().headerSelector;\n const header = selector ? this.document.querySelector<HTMLElement>(selector) : null;\n const measure = () => {\n const height = header?.getBoundingClientRect().height ?? 0;\n if (height !== this.headerHeight()) {\n this.measuredHeaderHeight.set(height);\n if (this.options().preserveOnResize) this.preserve();\n }\n };\n untracked(measure);\n if (header) {\n const observer = new ResizeObserver(measure);\n observer.observe(header);\n onCleanup(() => observer.disconnect());\n }\n });\n\n // Phase 2: Asynchronous scrollSource readiness, initial restoration, and data-change preservation.\n // Untracked execution guarantees execution triggers strictly on scrollSource readiness transitions.\n //\n // Note for developers: When scrollSource resolves (e.g. data loaded), target DOM elements may not\n // yet be rendered into the DOM (e.g. inside @for loops or child components).\n // Rather than discarding pendingDestination when execute() returns false (element not found),\n // we retain pendingDestination and schedule a bounded DOM MutationObserver retry to execute\n // immediately once child elements/anchor attributes are attached to the host DOM.\n afterRenderEffect(() => {\n const source = this.scrollSource();\n const state = readScrollSource(source);\n if (!state.ready) return;\n untracked(() => {\n if (!this.initialized) {\n this.initialized = true;\n if (!this.userInterrupted) {\n if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) {\n if (this.pendingDestination) this.scheduleDestinationRetry();\n }\n } else {\n this.liveAnchor.set(this.calculateNearestAnchor() ?? null);\n this.cancelDestinationRetry();\n }\n } else {\n if (!this.userInterrupted) {\n if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) {\n if (this.pendingDestination) {\n this.scheduleDestinationRetry();\n } else if (source !== NO_SCROLL_SOURCE && this.options().restoreOnDataChange) {\n this.preserve('top');\n }\n }\n }\n }\n });\n });\n\n // Phase 3: Route fragment synchronization to liveAnchor and external navigation handling.\n // When the route fragment changes externally (e.g., router navigation), synchronize liveAnchor\n // and trigger scroll restoration to the target element. If the target DOM element is not yet attached,\n // pendingDestination is preserved and scheduled for retry via MutationObserver.\n effect(() => {\n const frag = this.fragment();\n untracked(() => {\n const prev = this.lastProcessedFragment;\n this.lastProcessedFragment = frag;\n this.liveAnchor.set(frag);\n\n if (prev === undefined) {\n return;\n }\n\n if (frag) {\n if (!this.isInternalNavigation()) {\n this.userInterrupted = false;\n this.pendingDestination = frag;\n const source = this.scrollSource();\n const state = readScrollSource(source);\n if (this.initialized && state.ready) {\n if (!this.tryExecutePendingDestination(this.options().behavior.restoration)) {\n this.scheduleDestinationRetry();\n }\n } else {\n this.cancelDestinationRetry();\n }\n }\n } else {\n this.pendingDestination = null;\n this.cancelDestinationRetry();\n }\n });\n });\n\n // Phase 4: Scroll revision debounced publication to URL fragment.\n // Re-evaluates nearest anchor and commits it to the URL router after debounce delay.\n effect(() => {\n if (!this.scrollRevision()) return;\n const anchor = this.calculateNearestAnchor();\n if (anchor) {\n this.fragment.set(anchor);\n }\n });\n }\n\n /**\n * Determines whether the active router navigation was initiated internally by scroll-spy.\n */\n private isInternalNavigation(): boolean {\n const raw =\n typeof this.router?.getCurrentNavigation === 'function'\n ? (this.router.getCurrentNavigation() ?? this.router.lastSuccessfulNavigation)\n : this.router?.lastSuccessfulNavigation;\n const nav: Navigation | null =\n typeof raw === 'function' ? (raw as () => Navigation | null)() : (raw ?? null);\n const stateOrigin = (nav?.extras?.state as Record<string, unknown> | undefined)?.[\n MAGIC_SCROLL_ORIGIN_KEY\n ];\n const infoOrigin = (nav?.extras?.info as Record<string, unknown> | undefined)?.[\n MAGIC_SCROLL_ORIGIN_KEY\n ];\n return (\n infoOrigin === MAGIC_SCROLL_ORIGIN_SCROLL_SPY ||\n (nav?.trigger !== 'popstate' && stateOrigin === MAGIC_SCROLL_ORIGIN_SCROLL_SPY)\n );\n }\n\n /**\n * Explicitly scrolls the viewport to the target anchor identifier.\n *\n * @param anchor - Target anchor ID (with or without leading `#`).\n * @param options - Per-call scroll overrides (behavior, top offset, ignoreWhenInView).\n * @returns `true` if the target anchor was found and scrolling initiated; `false` otherwise.\n *\n * @remarks\n * Successful execution immediately synchronizes both `liveAnchor` and the router `fragment`\n * with the target anchor, overriding any in-flight debounce delays.\n */\n scrollTo(anchor: string, options: MagicScrollToOptions = {}): boolean {\n const executed = this.execute(anchor, {\n behavior: this.options().behavior.interaction,\n ignoreWhenInView: this.options().ignoreWhenInView,\n ...options,\n });\n if (executed) {\n const clean = anchor.replace(/^#/, '');\n this.liveAnchor.set(clean);\n this.pendingDestination = null;\n this.cancelDestinationRetry();\n this.fragment.set(clean);\n }\n return executed;\n }\n\n /**\n * Attempts to execute navigation to the pending destination anchor.\n *\n * @param behavior - The scroll animation behavior to apply (`instant` or `smooth`).\n * @returns `true` if the target anchor element was found in the DOM and scrolling was initiated; `false` otherwise.\n *\n * @remarks\n * If execution succeeds, `pendingDestination` is cleared and active retry observers/timers are canceled.\n * If target elements are not yet present in the DOM (e.g. data is ready before DOM elements render),\n * `pendingDestination` remains queued so subsequent DOM updates can retry.\n */\n private tryExecutePendingDestination(behavior: ScrollBehavior): boolean {\n if (!this.pendingDestination) return false;\n if (this.execute(this.pendingDestination, { behavior })) {\n this.pendingDestination = null;\n this.cancelDestinationRetry();\n return true;\n }\n return false;\n }\n\n /**\n * Schedules a bounded retry mechanism observing DOM additions and attribute modifications\n * within the directive host to re-attempt scrolling when the target anchor element is rendered.\n *\n * @remarks\n * Asynchronous rendering cycles (such as Angular `@for` loops, `@if` blocks, or asynchronous child components)\n * often resolve data/signals (`scrollSource`) before child DOM anchor nodes are attached to the document.\n *\n * To prevent dropped navigations without polling or manual timeouts, a `MutationObserver` watches host mutations\n * for child node insertions and anchor attribute modifications (`id`, `data-scroll-anchor`). When DOM mutations\n * occur and the scroll source is ready, destination execution is re-attempted.\n *\n * A bounded 1000ms safety timeout safeguards against indefinite observation if a non-existent anchor ID was requested.\n */\n private scheduleDestinationRetry(): void {\n if (!this.retryObserver) {\n this.retryObserver = new MutationObserver(() => {\n const state = readScrollSource(this.scrollSource());\n if (state.ready) {\n this.tryExecutePendingDestination(this.options().behavior.restoration);\n }\n });\n this.retryObserver.observe(this.host, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['id', 'data-scroll-anchor'],\n });\n }\n clearTimeout(this.retryTimeoutTimer);\n this.retryTimeoutTimer = setTimeout(() => this.cancelDestinationRetry(), 1000);\n }\n\n /**\n * Disconnects the DOM mutation observer and clears active retry timers.\n *\n * @remarks\n * Invoked whenever destination execution succeeds, a user interrupts navigation (via scroll, touch, or keyboard),\n * an explicit navigation supersedes the pending target, or the directive is destroyed.\n */\n private cancelDestinationRetry(): void {\n if (this.retryObserver) {\n this.retryObserver.disconnect();\n this.retryObserver = undefined;\n }\n clearTimeout(this.retryTimeoutTimer);\n this.retryTimeoutTimer = undefined;\n }\n\n /**\n * Queries and returns all eligible scroll anchor elements belonging strictly to this directive instance.\n *\n * @remarks\n * Anchors must possess a non-empty `id` and satisfy the configured selection strategy:\n * - In `'explicit'` mode: Only elements marked with the `data-scroll-anchor` attribute participate,\n * ignoring prefix rules and excluding generated component/form IDs.\n * - In `'all'` mode (default): Elements matching any configured `anchorPrefixes` OR bearing\n * the `data-scroll-anchor` attribute participate.\n *\n * The `closest('[magicScroll]') === this.host` check guarantees that nested child regions\n * retain strict isolation over their own anchors and do not leak across region boundaries.\n */\n private anchors(): HTMLElement[] {\n const { anchorSelection, anchorPrefixes } = this.options();\n return Array.from(this.host.querySelectorAll<HTMLElement>('[id]')).filter((el) => {\n // 1. Elements must have a non-empty ID and belong directly to this magicScroll host.\n if (!el.id || el.closest('[magicScroll]') !== this.host) {\n return false;\n }\n\n // 2. Strict explicit selection strategy: Ignore prefixes; admit only explicitly marked elements.\n if (anchorSelection === 'explicit') {\n return el.hasAttribute('data-scroll-anchor');\n }\n\n // 3. Default/all selection strategy: Admit explicitly marked elements or elements matching any configured prefix.\n return (\n el.hasAttribute('data-scroll-anchor') ||\n anchorPrefixes.some((prefix) => el.id.startsWith(prefix))\n );\n });\n }\n\n /**\n * Resolves the active scroll container element governing viewport offsets.\n *\n * @returns The scrollable container `HTMLElement`, or `undefined` when scrolling against the document root / window.\n */\n private scrollable(): HTMLElement | undefined {\n const overflow = getComputedStyle(this.host).overflowY;\n const container =\n overflow === 'auto' || overflow === 'scroll'\n ? this.host\n : injectScrollableParentElement(this.host);\n // Root scrolling emits events on document, even when overflow is set on body/html.\n return container === this.document.documentElement || container === this.document.body\n ? undefined\n : container;\n }\n\n /**\n * Computes the nearest visible anchor according to current viewport geometry, header offsets,\n * bottom offsets, and reading direction thresholds.\n */\n private calculateNearestAnchor(): string | undefined {\n return this.nearest.getNearestAnchor({\n hostElement: this.host,\n anchors: this.anchors(),\n scrollable: this.scrollable(),\n headerOffset: this.offset(),\n bottomOffset: this.options().bottomOffset,\n activationThreshold: this.options().activationThreshold,\n });\n }\n\n /**\n * Dispatches scrolling to the underlying {@link ScrollService} after validating target presence.\n */\n private execute(anchor: string | null | undefined, options: MagicScrollToOptions): boolean {\n if (!anchor || !this.anchors().some((el) => el.id === anchor.replace(/^#/, ''))) return false;\n return this.service.scroll(anchor, {\n scrollable: this.scrollable(),\n topOffset: this.offset(),\n ...options,\n });\n }\n\n /**\n * Realigns the viewport instantly to preserve position during asynchronous data updates\n * or container / header dimension changes.\n *\n * @remarks\n * Targets `liveAnchor` (the real-time visible section) rather than `fragment` (the debounced URL fragment)\n * to ensure rapid data or layout shifts during continuous scrolling maintain the user's active view\n * rather than jumping back to a stale URL anchor.\n */\n private preserve(ignoreWhenInView: ScrollOptions['ignoreWhenInView'] = 'none'): void {\n if (this.initialized) {\n this.execute(this.liveAnchor(), { behavior: 'instant', ignoreWhenInView });\n }\n }\n\n /**\n * Updates target container references and re-binds passive event listeners whenever\n * the scroll container shifts dynamically.\n */\n private refreshTarget(): void {\n const target = this.scrollable() ?? this.document;\n if (target !== this.target) {\n this.detachListeners();\n this.target = target;\n this.attachListeners(target);\n }\n }\n\n /**\n * Attaches passive scroll, gesture, and keyboard listeners to track user intent and scroll motion.\n */\n private attachListeners(target: EventTarget): void {\n target.addEventListener('scroll', this.onScroll);\n target.addEventListener('wheel', this.onUserIntent, { passive: true });\n target.addEventListener('touchstart', this.onUserIntent, { passive: true });\n target.addEventListener('touchmove', this.onUserIntent, { passive: true });\n target.addEventListener('keydown', this.onKeyDown);\n this.document.addEventListener('keydown', this.onKeyDown);\n }\n\n /**\n * Detaches all registered event listeners to ensure clean teardown without memory leaks.\n */\n private detachListeners(): void {\n if (!this.target) return;\n this.target.removeEventListener('scroll', this.onScroll);\n this.target.removeEventListener('wheel', this.onUserIntent);\n this.target.removeEventListener('touchstart', this.onUserIntent);\n this.target.removeEventListener('touchmove', this.onUserIntent);\n this.target.removeEventListener('keydown', this.onKeyDown);\n this.document.removeEventListener('keydown', this.onKeyDown);\n }\n\n /**\n * Intercepts manual user gestures (`wheel`, `touchstart`, `touchmove`) to flag user interaction\n * and cancel delayed restorations prior to data readiness.\n */\n private readonly onUserIntent = () => {\n this.userInterrupted = true;\n this.pendingDestination = null;\n this.cancelDestinationRetry();\n };\n\n /**\n * Intercepts navigation keyboard events (`ArrowUp`, `ArrowDown`, `PageUp`, `PageDown`, `Home`, `End`, `Space`).\n * Cancels pending restoration when navigation occurs within the relevant scroll context\n * while safely ignoring interactions on editable input elements.\n */\n private readonly onKeyDown = (event: Event) => {\n if (!(event instanceof KeyboardEvent)) return;\n if (isEditableTarget(event.target)) return;\n if (!SCROLL_KEYS.has(event.key)) return;\n const targetEl = event.target as Element | null;\n const scrollable = this.scrollable();\n const isInsideContext =\n !targetEl ||\n targetEl === this.document.body ||\n targetEl === this.document.documentElement ||\n (scrollable ? scrollable === targetEl || scrollable.contains(targetEl) : true);\n if (isInsideContext) {\n this.userInterrupted = true;\n this.pendingDestination = null;\n this.cancelDestinationRetry();\n }\n };\n\n /**\n * Handles scroll events by immediately updating `liveAnchor` for position preservation,\n * while debouncing `scrollRevision` increments to update the browser URL fragment without thrashing history.\n */\n private readonly onScroll = () => {\n if (!this.initialized) return;\n const anchor = this.calculateNearestAnchor();\n if (anchor) this.liveAnchor.set(anchor);\n clearTimeout(this.timer);\n this.timer = setTimeout(\n () => this.scrollRevision.update((revision) => revision + 1),\n this.options().debounceTime,\n );\n };\n\n /**\n * Handles window resize events by re-evaluating targets and optionally realigning the active anchor.\n */\n private readonly onResize = () => {\n this.refreshTarget();\n if (this.options().preserveOnResize) this.preserve();\n };\n}\n","import { Directive, effect, ElementRef, inject, input } from '@angular/core';\n\n/**\n * Optional explicit anchor. Its ID participates even outside the configured prefix.\n * Use `scrollAnchor` alongside an existing id, or `[scrollAnchor]=\"item.id\"` to set it.\n */\n@Directive({\n selector: '[scrollAnchor]',\n host: { '[attr.data-scroll-anchor]': '\"\"' },\n})\nexport class ScrollAnchorDirective {\n readonly scrollAnchor = input('');\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n constructor() {\n effect(() => {\n const id = this.scrollAnchor();\n if (id) this.host.nativeElement.id = id;\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAOA,MAAa,0BAA0B;AAKvC,MAAa,iCAAiC;AA2B9C,SAAgB,sBAAmB;CACjC,MAAM,QAAQ,OAAO,cAAc;CACnC,MAAM,SAAS,OAAO,MAAM;CAC5B,MAAM,WAAW,SAAS,MAAM,QAAQ;CAExC,MAAM,iBAAiB,mBAAmB,SAAQ,GAAG,GAAA,YAAA,CAAA,EAAA,WAAA,iBAAA,CAAA,+BAAA,CAAA,CAAA;CAErD,aAAY;EACV,MAAM,cAAc,eAAc;EAElC,IAD6B,SACzB,MAAyB,aAAa;GACxC,MAAM,OAAO,OAAO,cAAc,CAAA,GAAI;IACpC,YAAY;IACZ,UAAU,eAAe;IACzB,qBAAqB;GACtB,CAAA;GAED,OACG,cAAc,MAAM;IACnB,YAAY;IACZ,OAAO;KACL,GAAG,QAAQ;MACV,0BAA0B;IAC5B;IACD,MAAM,GAAG,0BAA0B,+BAA8B;GAClE,CAAA,CAAC,CACD,OAAO,UAAgB;IACtB,QAAQ,IAAI,qBAAqB,eAAe,KAAK;GACvD,CAAC;EACL;CACF,CAAC;CAED,OAAO;AACT;ACnDA,SAAgB,8BACd,aAAgC;CAEhC,SAAS,qBAAqB,IAAe;EAC3C,IAAI,UAAU,GAAG,iBAAiB;EAClC,OAAO,SAAS;GACd,MAAM,YAAY,iBAAiB,OAAO,CAAC,CAAC;GAC5C,IACE,cAAc,UACd,cAAc,UAEd,OAAO;GAET,UAAU,QAAQ,iBAAiB;EACrC;CAEF;CAEA,OAAO,qBACL,eAAgB,OAAO,UAAuB,CAAC,CAAC,aAA6B;AAEjF;AC5BA,SAAgB,qBAAqB,gBAAwB,UAAkB;CAC7E,MAAM,cAAc,SAAS,cAA2B,cAAc,CAAC,EAAE,sBAAqB;CAC9F,IAAI,CAAC,aAAa;EAChB,QAAQ,KAAK,6CAA6C,cAAc;EACxE,OAAO;CACT;CAEA,OAAO,KAAK,IAAI,YAAY,MAAM;AACpC;ACkCA,IAAa,wBAAb,MAAa,sBAAqB;CACf,WAAW,OAAO,QAAQ;CAuBpC,iBAAiB,SAA8B;EACpD,MAAM,OAAO,SAAS,eAAe,KAAK;EAC1C,MAAM,WAAW,SAAS,YAAY;EACtC,MAAM,UACH,SAAS,gBAAgB,MACzB,SAAS,iBAAiB,qBAAqB,QAAQ,gBAAgB,KAAK,QAAQ,IAAI;EAG3F,MAAM,UAAU,SAAS,WAAW,MAAM,KAAK,KAAK,iBAA8B,QAAQ,CAAC;EAC3F,IAAI,QAAQ,WAAW,GACrB;EAGF,MAAM,aACJ,SAAS,eACR,SAAS,cAAc,8BAA8B,QAAQ,WAAW,IAAI;EAC/E,MAAM,aACJ,CAAC,cACD,eAAe,KAAK,SAAS,mBAC7B,eAAe,KAAK,SAAS;EAC/B,MAAM,eAAe,aAAa,IAAI,WAAW,sBAAqB,CAAE,CAAC,OAAO;EAChF,MAAM,iBAAiB,aAClB,KAAK,SAAS,aAAa,eAAe,KAAK,SAAS,gBAAgB,eACzE,WAAW,sBAAqB,CAAE,CAAC;EACvC,MAAM,iBAAiB,KAAK,IAC1B,aACA,iBAAiB,KAAK,IAAI,GAAG,SAAS,gBAAgB,CAAC,CAAC;EAI1D,MAAM,mBAAmB,QAAQ,QAAQ,OAAM;GAC7C,MAAM,OAAO,GAAG,sBAAqB;GACrC,OAAO,KAAK,OAAO,cAAc,KAAK,KAAK,UAAU,iBAAiB;EACxE,CAAC;EAED,IAAI,iBAAiB,SAAS,GAK5B,OAHyB,iBAAiB,QAAQ,MAAM,OACtD,GAAG,sBAAqB,CAAE,CAAC,MAAM,KAAK,sBAAqB,CAAE,CAAC,MAAM,KAAK,IAEpE,CAAA,CAAiB;EAI1B,MAAM,YAAY,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,SAAS,uBAAuB,CAAC,CAAC;EAC5E,MAAM,cAAc,eAAe,iBAAiB,eAAe;EACnE,MAAM,gBAAgB,QAAQ,QAAQ,OAAO,GAAG,sBAAqB,CAAE,CAAC,OAAO,cAAc,CAAC;EAE9F,IAAI,cAAc,SAAS,GAKzB,OAHe,cAAc,QAAQ,MAAM,OACzC,GAAG,sBAAqB,CAAE,CAAC,MAAM,KAAK,sBAAqB,CAAE,CAAC,MAAM,KAAK,IAEpE,CAAA,CAAO;EAOhB,OAHc,QAAQ,QAAQ,MAAM,OAClC,GAAG,sBAAqB,CAAE,CAAC,MAAM,KAAK,sBAAqB,CAAE,CAAC,MAAM,KAAK,IAEpE,CAAA,CAAM;CACf;CArFW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAqB,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAArB,OAAA,QAAA,GAAA,sBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;CAAqB,CAAA;;AAArB,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAqB,YAAA,CAAA,EADjC,MAAA,WAAA,CAAA;;ACfD,IAAa,gBAAb,MAAa,cAAa;CACP,WAAW,OAAiB,QAAQ;CACpC,aAAa;CAsB9B,OAAO,QAAgB,SAAuB;EAC5C,MAAM,YAAY,OAAO,WAAW,GAAG,IAAI,OAAO,MAAM,CAAC,IAAI;EAC7D,MAAM,KAAK,KAAK,SAAS,eAAe,SAAS;EAEjD,IAAI,CAAC,IACH,OAAO;EAGT,MAAM,aACJ,SAAS,cACR,KAAK,SAAS,oBACf,KAAK,SAAS;EAChB,MAAM,WAAW,SAAS,YAAY;EACtC,MAAM,mBAAmB,SAAS,oBAAoB;EACtD,MAAM,YAAY,SAAS,aAAa,KAAK;EAE7C,MAAM,OAAO,GAAG,sBAAqB;EACrC,MAAM,aACJ,eAAe,KAAK,SAAS,mBAAmB,eAAe,KAAK,SAAS;EAC/E,MAAM,eAAe,aAAa,IAAI,WAAW,sBAAqB,CAAE,CAAC;EACzE,MAAM,iBAAiB,aACnB,KAAK,SAAS,gBAAgB,eAC9B,WAAW;EACf,MAAM,cACJ,KAAK,OAAO,eAAe,YAAY,KAAK,KAAK,MAAM,eAAe;EACxE,MAAM,iBACJ,KAAK,UAAU,eAAe,kBAAkB,KAAK,SAAS;EAEhE,IACG,oBAAoB,SAAS,eAC7B,oBAAoB,UAAU,eAAe,kBAC7C,oBAAoB,aAAa,eAAe,iBAEjD,OAAO;EAIT,WAAW,SAAS;GAClB,KAAK,WAAW,YAAY,KAAK,MAAM,eAAe;GACtD;EACD,CAAA;EAED,OAAO;CACT;CAnEW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAa,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAAb,OAAA,QAAA,GAAA,sBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAa,YAFZ;CAAM,CAAA;;AAEP,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAa,YAAA,CAAA;EAHzB,MAAA;EAAW,MAAA,CAAA,EACV,YAAY,OACb,CAAA;;;ACsBD,MAAM,WAAuC;CAC3C,gBAAgB,CAAC,EAAE;CACnB,iBAAiB;CACjB,gBAAgB;CAChB,cAAc;CACd,cAAc;CACd,qBAAqB;CACrB,UAAU;EAAE,aAAa;EAAW,aAAa;CAAQ;CACzD,cAAc;CACd,kBAAkB;CAClB,qBAAqB;CACrB,kBAAkB;AACnB;AAED,SAAgB,0BACd,QACA,SAA2B;CAE3B,OAAO;EACL,gBAAgB,QAAQ,kBAAkB,OAAO;EACjD,iBAAiB,QAAQ,mBAAmB,OAAO;EACnD,gBAAgB,QAAQ,kBAAkB,OAAO;EACjD,cAAc,QAAQ,gBAAgB,OAAO;EAC7C,cAAc,QAAQ,gBAAgB,OAAO;EAC7C,qBAAqB,QAAQ,uBAAuB,OAAO;EAC3D,UAAU;GACR,aAAa,QAAQ,UAAU,eAAe,OAAO,SAAS;GAC9D,aAAa,QAAQ,UAAU,eAAe,OAAO,SAAS;EAC/D;EACD,cAAc,QAAQ,gBAAgB,OAAO;EAC7C,kBAAkB,QAAQ,oBAAoB,OAAO;EACrD,qBAAqB,QAAQ,uBAAuB,OAAO;EAC3D,kBAAkB,QAAQ,oBAAoB,OAAO;CACtD;AACH;AAEA,MAAa,uBAAuB,IAAI,eACtC,yBACA;CAAE,YAAY;CAAQ,eAAe;AAAQ,CAAE;AAUjD,SAAgB,mBAAmB,UAA8B,CAAA,GAAE;CACjE,OAAO;EACL,SAAS;EACT,kBACE,0BACE,OAAO,sBAAsB;GAAE,UAAU;GAAM,UAAU;EAAI,CAAE,KAAK,UACpE,OAAO;CAEZ;AACH;AC3FA,MAAa,mBAAmB,OAAO,+BAA+B;AAGtE,SAAgB,iBAAiB,QAA8C;CAI7E,IAAI,WAAW,kBAAkB,OAAO;EAAE,OAAO;EAAM,MAAM;CAAM;CACnE,IAAI,OAAgB;CACpB,OAAO,SAAS,IAAI,GAAG,OAAO,KAAI;CAClC,IAAI,SAAS,QAAQ,OAAO,SAAS,UAAU;EAG7C,MAAM,YAAY;EAClB,IAAI,OAAO,UAAU,cAAc,YAAY;GAC7C,IAAI,UAAU,UAAS,GAAI,OAAO;IAAE,OAAO;IAAO,MAAM;GAAS;GACjE,IAAI,WAAW,QAAQ,OAAO,KAAK,UAAU,YAAY;IACvD,MAAM,WAAW;IAEjB,OAAO,SAAS,YAAY,CAAC,SAAS,SAAQ,IAAK,SAAY,SAAS,MAAK;GAC/E,OAAO,IAAI,OAAO,UAAU,SAAS,YACnC,OAAO,UAAU,KAAI;EAEzB;CACF;CACA,OAAO;EAAE,OAAO,SAAS,UAAa,SAAS;EAAM;CAAI;AAC3D;ACTA,MAAM,8BAAc,IAAI,IAAI;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACD,CAAA;AAOD,SAAS,iBAAiB,QAA0B;CAClD,IAAI,CAAC,UAAU,EAAE,kBAAkB,UAAU,OAAO;CACpD,MAAM,UAAU,OAAO,QAAQ,YAAW;CAC1C,IAAI,YAAY,WAAW,YAAY,cAAc,YAAY,UAAU,OAAO;CAElF,MAAM,gBADe,OAAO,QAAQ,mBACd,CAAA,EAAc,aAAa,iBAAiB,CAAC,EAAE,YAAW;CAChF,OAAO,QACJ,OAAuB,qBACxB,OAAO,QAAQ,yBAAyB,KACxC,kBAAkB,MAClB,kBAAkB,UAClB,kBAAkB,gBAAgB;AAEtC;AAyCA,IAAa,uBAAb,MAAa,qBAAoB;CAOtB,eAAe,MACtB,kBAAgB;EAAA,GAAA,YAAA,EAAA,WAAA,eAAA,+BAAA,CAAA;EACd,YAAY,UAAU;CAAK,CAAA;CAItB,gBAAgB,MAA0B,CAAA,GAAG,GAAA,YAAA,CAAA,EAAA,WAAA,gBAAA,CAAA,+BAAA,CAAA,CAAA;CAErC,WAAW,OAAO,oBAAoB;CACtC,UAAU,eACzB,0BAA0B,KAAK,UAAU,KAAK,cAAa,CAAE,GAC9D,GAAA,YAAA,CAAA,EAAA,WAAA,UAAA,CAAA,+BAAA,CAAA,CAAA;CACgB,WAAW,OAAO,QAAQ;CAC1B,OAAO,OAAgC,UAAU,CAAC,CAAC;CACnD,UAAU,OAAO,aAAa;CAC9B,UAAU,OAAO,qBAAqB;CACtC,SAAS,OAAO,QAAQ,EAAE,UAAU,KAAI,CAAE;CAG1C,WAAW,oBAAmB;CAG9B,gBAAgB,OAAO,cAAc,CAAC,CAAC,SAAS;CAGhD,uBAAuB,OAAO,GAAE,GAAA,YAAA,CAAA,EAAA,WAAA,uBAAA,CAAA,+BAAA,CAAA,CAAA;CAOhC,aAAa,OAAkC,MAAK,GAAA,YAAA,CAAA,EAAA,WAAA,aAAA,CAAA,+BAAA,CAAA,CAAA;CAG5D,eAAe,KAAK,SAAS,WAAU;CAGvC,eAAe,KAAK,qBAAqB,WAAU;CAGnD,SAAS,eAAe,KAAK,aAAY,IAAK,KAAK,QAAO,CAAE,CAAC,cAAa,GAAA,YAAA,CAAA,EAAA,WAAA,SAAA,CAAA,+BAAA,CAAA,CAAA;CAGlE,iBAAiB,OAAO,GAAE,GAAA,YAAA,CAAA,EAAA,WAAA,iBAAA,CAAA,+BAAA,CAAA,CAAA;CAGnC,cAAc;CAMd,kBAAkB;CAGlB,qBAAoC,KAAK,iBAAiB;CAG1D,wBAAmD;CAEnD;CACA;CAEA;CAEA;CAER,cAAA;EACE,MAAM,aAAa,OAAO,UAAU;EACpC,sBAAqB;GACnB,KAAK,cAAa;GAClB,KAAK,SAAS,aAAa,iBAAiB,UAAU,KAAK,QAAQ;EACrE,CAAC;EACD,WAAW,gBAAe;GACxB,KAAK,gBAAe;GACpB,KAAK,SAAS,aAAa,oBAAoB,UAAU,KAAK,QAAQ;GACtE,aAAa,KAAK,KAAK;GACvB,KAAK,uBAAsB;GAC3B,KAAK,qBAAqB;EAC5B,CAAC;EAID,mBAAmB,cAAa;GAC9B,MAAM,WAAW,KAAK,QAAO,CAAE,CAAC;GAChC,MAAM,SAAS,WAAW,KAAK,SAAS,cAA2B,QAAQ,IAAI;GAC/E,MAAM,gBAAe;IACnB,MAAM,SAAS,QAAQ,sBAAqB,CAAE,CAAC,UAAU;IACzD,IAAI,WAAW,KAAK,aAAY,GAAI;KAClC,KAAK,qBAAqB,IAAI,MAAM;KACpC,IAAI,KAAK,QAAO,CAAE,CAAC,kBAAkB,KAAK,SAAQ;IACpD;GACF;GACA,UAAU,OAAO;GACjB,IAAI,QAAQ;IACV,MAAM,WAAW,IAAI,eAAe,OAAO;IAC3C,SAAS,QAAQ,MAAM;IACvB,gBAAgB,SAAS,WAAU,CAAE;GACvC;EACF,CAAC;EAUD,wBAAuB;GACrB,MAAM,SAAS,KAAK,aAAY;GAEhC,IAAI,CADU,iBAAiB,MAC1B,CAAA,CAAM,OAAO;GAClB,gBAAe;IACb,IAAI,CAAC,KAAK,aAAa;KACrB,KAAK,cAAc;KACnB,IAAI,CAAC,KAAK,iBACR;UAAI,CAAC,KAAK,6BAA6B,KAAK,QAAO,CAAE,CAAC,SAAS,WAAW,GACxE;WAAI,KAAK,oBAAoB,KAAK,yBAAwB;MAAA;KAC5D,OACK;MACL,KAAK,WAAW,IAAI,KAAK,uBAAsB,KAAM,IAAI;MACzD,KAAK,uBAAsB;KAC7B;IACF,OACE,IAAI,CAAC,KAAK,iBACR;SAAI,CAAC,KAAK,6BAA6B,KAAK,QAAO,CAAE,CAAC,SAAS,WAAW,GAAG;MAC3E,IAAI,KAAK,oBACP,KAAK,yBAAwB;WACxB,IAAI,WAAW,oBAAoB,KAAK,QAAO,CAAE,CAAC,qBACvD,KAAK,SAAS,KAAK;KAEvB;;GAGN,CAAC;EACH,CAAC;EAMD,aAAY;GACV,MAAM,OAAO,KAAK,SAAQ;GAC1B,gBAAe;IACb,MAAM,OAAO,KAAK;IAClB,KAAK,wBAAwB;IAC7B,KAAK,WAAW,IAAI,IAAI;IAExB,IAAI,SAAS,QACX;IAGF,IAAI,MACF;SAAI,CAAC,KAAK,qBAAoB,GAAI;MAChC,KAAK,kBAAkB;MACvB,KAAK,qBAAqB;MAE1B,MAAM,QAAQ,iBADC,KAAK,aACW,CAAM;MACrC,IAAI,KAAK,eAAe,MAAM,OAC5B;WAAI,CAAC,KAAK,6BAA6B,KAAK,QAAO,CAAE,CAAC,SAAS,WAAW,GACxE,KAAK,yBAAwB;MAC/B,OAEA,KAAK,uBAAsB;KAE/B;WACK;KACL,KAAK,qBAAqB;KAC1B,KAAK,uBAAsB;IAC7B;GACF,CAAC;EACH,CAAC;EAID,aAAY;GACV,IAAI,CAAC,KAAK,eAAc,GAAI;GAC5B,MAAM,SAAS,KAAK,uBAAsB;GAC1C,IAAI,QACF,KAAK,SAAS,IAAI,MAAM;EAE5B,CAAC;CACH;CAKQ,uBAAoB;EAC1B,MAAM,MACJ,OAAO,KAAK,QAAQ,yBAAyB,aACxC,KAAK,OAAO,qBAAoB,KAAM,KAAK,OAAO,2BACnD,KAAK,QAAQ;EACnB,MAAM,MACJ,OAAO,QAAQ,aAAc,IAA+B,IAAM,OAAO;EAC3E,MAAM,cAAe,KAAK,QAAQ,QAChC;EAKF,OAHoB,KAAK,QAAQ,OAC/B,6BAGA,gBACC,KAAK,YAAY,cAAc,gBAAA;CAEpC;CAaA,SAAS,QAAgB,UAAgC,CAAA,GAAE;EACzD,MAAM,WAAW,KAAK,QAAQ,QAAQ;GACpC,UAAU,KAAK,QAAO,CAAE,CAAC,SAAS;GAClC,kBAAkB,KAAK,QAAO,CAAE,CAAC;GACjC,GAAG;EACJ,CAAA;EACD,IAAI,UAAU;GACZ,MAAM,QAAQ,OAAO,QAAQ,MAAM,EAAE;GACrC,KAAK,WAAW,IAAI,KAAK;GACzB,KAAK,qBAAqB;GAC1B,KAAK,uBAAsB;GAC3B,KAAK,SAAS,IAAI,KAAK;EACzB;EACA,OAAO;CACT;CAaQ,6BAA6B,UAAwB;EAC3D,IAAI,CAAC,KAAK,oBAAoB,OAAO;EACrC,IAAI,KAAK,QAAQ,KAAK,oBAAoB,EAAE,SAAQ,CAAE,GAAG;GACvD,KAAK,qBAAqB;GAC1B,KAAK,uBAAsB;GAC3B,OAAO;EACT;EACA,OAAO;CACT;CAgBQ,2BAAwB;EAC9B,IAAI,CAAC,KAAK,eAAe;GACvB,KAAK,gBAAgB,IAAI,uBAAsB;IAE7C,IADc,iBAAiB,KAAK,aAAY,CAC5C,CAAA,CAAM,OACR,KAAK,6BAA6B,KAAK,QAAO,CAAE,CAAC,SAAS,WAAW;GAEzE,CAAC;GACD,KAAK,cAAc,QAAQ,KAAK,MAAM;IACpC,WAAW;IACX,SAAS;IACT,YAAY;IACZ,iBAAiB,CAAC,MAAM,oBAAoB;GAC7C,CAAA;EACH;EACA,aAAa,KAAK,iBAAiB;EACnC,KAAK,oBAAoB,iBAAiB,KAAK,uBAAsB,GAAI,GAAI;CAC/E;CASQ,yBAAsB;EAC5B,IAAI,KAAK,eAAe;GACtB,KAAK,cAAc,WAAU;GAC7B,KAAK,gBAAgB;EACvB;EACA,aAAa,KAAK,iBAAiB;EACnC,KAAK,oBAAoB;CAC3B;CAeQ,UAAO;EACb,MAAM,EAAE,iBAAiB,mBAAmB,KAAK,QAAO;EACxD,OAAO,MAAM,KAAK,KAAK,KAAK,iBAA8B,MAAM,CAAC,CAAC,CAAC,QAAQ,OAAM;GAE/E,IAAI,CAAC,GAAG,MAAM,GAAG,QAAQ,eAAe,MAAM,KAAK,MACjD,OAAO;GAIT,IAAI,oBAAoB,YACtB,OAAO,GAAG,aAAa,oBAAoB;GAI7C,OACE,GAAG,aAAa,oBAAoB,KACpC,eAAe,MAAM,WAAW,GAAG,GAAG,WAAW,MAAM,CAAC;EAE5D,CAAC;CACH;CAOQ,aAAU;EAChB,MAAM,WAAW,iBAAiB,KAAK,IAAI,CAAC,CAAC;EAC7C,MAAM,YACJ,aAAa,UAAU,aAAa,WAChC,KAAK,OACL,8BAA8B,KAAK,IAAI;EAE7C,OAAO,cAAc,KAAK,SAAS,mBAAmB,cAAc,KAAK,SAAS,OAC9E,SACA;CACN;CAMQ,yBAAsB;EAC5B,OAAO,KAAK,QAAQ,iBAAiB;GACnC,aAAa,KAAK;GAClB,SAAS,KAAK,QAAO;GACrB,YAAY,KAAK,WAAU;GAC3B,cAAc,KAAK,OAAM;GACzB,cAAc,KAAK,QAAO,CAAE,CAAC;GAC7B,qBAAqB,KAAK,QAAO,CAAE,CAAC;EACrC,CAAA;CACH;CAKQ,QAAQ,QAAmC,SAA6B;EAC9E,IAAI,CAAC,UAAU,CAAC,KAAK,QAAO,CAAE,CAAC,MAAM,OAAO,GAAG,OAAO,OAAO,QAAQ,MAAM,EAAE,CAAC,GAAG,OAAO;EACxF,OAAO,KAAK,QAAQ,OAAO,QAAQ;GACjC,YAAY,KAAK,WAAU;GAC3B,WAAW,KAAK,OAAM;GACtB,GAAG;EACJ,CAAA;CACH;CAWQ,SAAS,mBAAsD,QAAM;EAC3E,IAAI,KAAK,aACP,KAAK,QAAQ,KAAK,WAAU,GAAI;GAAE,UAAU;GAAW;EAAgB,CAAE;CAE7E;CAMQ,gBAAa;EACnB,MAAM,SAAS,KAAK,WAAU,KAAM,KAAK;EACzC,IAAI,WAAW,KAAK,QAAQ;GAC1B,KAAK,gBAAe;GACpB,KAAK,SAAS;GACd,KAAK,gBAAgB,MAAM;EAC7B;CACF;CAKQ,gBAAgB,QAAmB;EACzC,OAAO,iBAAiB,UAAU,KAAK,QAAQ;EAC/C,OAAO,iBAAiB,SAAS,KAAK,cAAc,EAAE,SAAS,KAAI,CAAE;EACrE,OAAO,iBAAiB,cAAc,KAAK,cAAc,EAAE,SAAS,KAAI,CAAE;EAC1E,OAAO,iBAAiB,aAAa,KAAK,cAAc,EAAE,SAAS,KAAI,CAAE;EACzE,OAAO,iBAAiB,WAAW,KAAK,SAAS;EACjD,KAAK,SAAS,iBAAiB,WAAW,KAAK,SAAS;CAC1D;CAKQ,kBAAe;EACrB,IAAI,CAAC,KAAK,QAAQ;EAClB,KAAK,OAAO,oBAAoB,UAAU,KAAK,QAAQ;EACvD,KAAK,OAAO,oBAAoB,SAAS,KAAK,YAAY;EAC1D,KAAK,OAAO,oBAAoB,cAAc,KAAK,YAAY;EAC/D,KAAK,OAAO,oBAAoB,aAAa,KAAK,YAAY;EAC9D,KAAK,OAAO,oBAAoB,WAAW,KAAK,SAAS;EACzD,KAAK,SAAS,oBAAoB,WAAW,KAAK,SAAS;CAC7D;CAMiB,qBAAoB;EACnC,KAAK,kBAAkB;EACvB,KAAK,qBAAqB;EAC1B,KAAK,uBAAsB;CAC7B;CAOiB,aAAa,UAAgB;EAC5C,IAAI,EAAE,iBAAiB,gBAAgB;EACvC,IAAI,iBAAiB,MAAM,MAAM,GAAG;EACpC,IAAI,CAAC,YAAY,IAAI,MAAM,GAAG,GAAG;EACjC,MAAM,WAAW,MAAM;EACvB,MAAM,aAAa,KAAK,WAAU;EAMlC,IAJE,CAAC,YACD,aAAa,KAAK,SAAS,QAC3B,aAAa,KAAK,SAAS,oBAC1B,aAAa,eAAe,YAAY,WAAW,SAAS,QAAQ,IAAI,OACtD;GACnB,KAAK,kBAAkB;GACvB,KAAK,qBAAqB;GAC1B,KAAK,uBAAsB;EAC7B;CACF;CAMiB,iBAAgB;EAC/B,IAAI,CAAC,KAAK,aAAa;EACvB,MAAM,SAAS,KAAK,uBAAsB;EAC1C,IAAI,QAAQ,KAAK,WAAW,IAAI,MAAM;EACtC,aAAa,KAAK,KAAK;EACvB,KAAK,QAAQ,iBACL,KAAK,eAAe,QAAQ,aAAa,WAAW,CAAC,GAC3D,KAAK,QAAO,CAAE,CAAC,YAAY;CAE/B;CAKiB,iBAAgB;EAC/B,KAAK,cAAa;EAClB,IAAI,KAAK,QAAO,CAAE,CAAC,kBAAkB,KAAK,SAAQ;CACpD;CAjfW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAoB,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAApB,OAAA,OAAA,GAAA,qBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,MAAA;EAAoB,cAAA;EAAA,UAAA;EAAA,QAAA;GAAA,cAAA;IAAA,mBAAA;IAAA,YAAA;IAAA,UAAA;IAAA,YAAA;IAAA,mBAAA;GAAA;GAAA,eAAA;IAAA,mBAAA;IAAA,YAAA;IAAA,UAAA;IAAA,YAAA;IAAA,mBAAA;GAAA;EAAA;EAAA,MAAA,EAAA,YAAA,EAAA,oBAAA,OAAA,EAAA;EAAA,WAHpB,CAAC,qBAAqB;EAAC,UAAA,CAAA,aAAA;EAAA,UAAA;CAAA,CAAA;;AAGvB,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAoB,YAAA,CAAA;EANhC,MAAA;EAAU,MAAA,CAAA;GACT,UAAU;GACV,UAAU;GACV,WAAW,CAAC,qBAAqB;GACjC,MAAM,EAAE,sBAAsB,OAAI;EACnC,CAAA;;;;;;;;;;;;;;;;;;;;;;ACxGD,IAAa,wBAAb,MAAa,sBAAqB;CACvB,eAAe,MAAM,IAAG,GAAA,YAAA,CAAA,EAAA,WAAA,eAAA,CAAA,+BAAA,CAAA,CAAA;CAChB,OAAO,OAAgC,UAAU;CAClE,cAAA;EACE,aAAY;GACV,MAAM,KAAK,KAAK,aAAY;GAC5B,IAAI,IAAI,KAAK,KAAK,cAAc,KAAK;EACvC,CAAC;CACH;CARW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAAqB,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAArB,OAAA,OAAA,GAAA,qBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,MAAA;EAAqB,cAAA;EAAA,UAAA;EAAA,QAAA,EAAA,cAAA;GAAA,mBAAA;GAAA,YAAA;GAAA,UAAA;GAAA,YAAA;GAAA,mBAAA;EAAA,EAAA;EAAA,MAAA,EAAA,YAAA,EAAA,2BAAA,OAAA,EAAA;EAAA,UAAA;CAAA,CAAA;;AAArB,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAAqB,YAAA,CAAA;EAJjC,MAAA;EAAU,MAAA,CAAA;GACT,UAAU;GACV,MAAM,EAAE,6BAA6B,OAAI;EAC1C,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devzwo/ngx-magic-scroll",
3
- "version": "0.0.1-rc.2",
3
+ "version": "1.0.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": "^22.2.0",
6
6
  "@angular/core": "^22.2.0",
@@ -60,10 +60,28 @@ declare class ScrollService {
60
60
  static ɵfac: i0.ɵɵFactoryDeclaration<ScrollService, never>;
61
61
  static ɵprov: i0.ɵɵInjectableDeclaration<any>;
62
62
  }
63
+ /**
64
+ * Anchor selection strategy for a `magicScroll` region.
65
+ * - `'all'`: Default strategy. Descendant elements matching any configured `anchorPrefixes`
66
+ * (or all non-empty IDs if no prefix is configured) as well as explicit `scrollAnchor` markers participate.
67
+ * - `'explicit'`: Strict explicit-only mode. Only elements marked with `[scrollAnchor]` or `scrollAnchor`
68
+ * participate, regardless of their element ID or configured prefixes.
69
+ */
70
+ type AnchorSelectionMode = 'all' | 'explicit';
63
71
  /** Defaults for an application or overrides for one `magicScroll` region. */
64
72
  interface MagicScrollOptions {
65
- /** All descendant IDs participate when empty. Explicit `scrollAnchor`s also participate. */
66
- anchorPrefix?: string;
73
+ /**
74
+ * Allowed anchor ID prefixes. Descendant elements matching any prefix participate.
75
+ * Explicit `scrollAnchor` markers also participate.
76
+ * Passing an empty array `[]` disables prefix matching so only explicit markers participate.
77
+ */
78
+ anchorPrefixes?: readonly string[] | string[];
79
+ /**
80
+ * Selection strategy for anchor discovery.
81
+ * - `'all'`: Selects elements matching prefixes or explicit markers (default).
82
+ * - `'explicit'`: Selects only elements with explicit `scrollAnchor` markers.
83
+ */
84
+ anchorSelection?: AnchorSelectionMode;
67
85
  /** Selector for a fixed/sticky header whose height is measured automatically. */
68
86
  headerSelector?: string;
69
87
  /** Gap below the header, or the complete top offset when no header is selected. */
@@ -85,7 +103,8 @@ interface MagicScrollOptions {
85
103
  preserveOnResize?: boolean;
86
104
  }
87
105
  interface ResolvedMagicScrollOptions {
88
- anchorPrefix: string;
106
+ anchorPrefixes: readonly string[];
107
+ anchorSelection: AnchorSelectionMode;
89
108
  headerSelector: string;
90
109
  headerOffset: number;
91
110
  bottomOffset: number;
@@ -106,7 +125,7 @@ declare const MAGIC_SCROLL_OPTIONS: InjectionToken<ResolvedMagicScrollOptions>;
106
125
  * Per-region `scrollOptions` and per-call `scrollTo` options take precedence.
107
126
  *
108
127
  * @example
109
- * provideMagicScroll({ anchorPrefix: 'project-', behavior: { interaction: 'smooth' } })
128
+ * provideMagicScroll({ anchorPrefixes: ['project-'], behavior: { interaction: 'smooth' } })
110
129
  */
111
130
  export declare function provideMagicScroll(options?: MagicScrollOptions): Provider;
112
131
  /** Reactive getters must read signals so loading and data changes are tracked.
@@ -190,6 +209,7 @@ export declare class MagicScrollDirective {
190
209
  private readonly host;
191
210
  private readonly service;
192
211
  private readonly nearest;
212
+ private readonly router;
193
213
  /** Bidirectional route fragment synchronization signal (debounced URL writer and listener). */
194
214
  private readonly fragment;
195
215
  /** Initial route URL fragment snapshot captured upon directive instantiation. */
@@ -214,12 +234,24 @@ export declare class MagicScrollDirective {
214
234
  private initialized;
215
235
  /**
216
236
  * Flag indicating whether manual user interaction (wheel, touch, or navigation keys) occurred
217
- * before asynchronous data loading became ready. When true, delayed initial restoration is skipped.
237
+ * before asynchronous data loading became ready. When true, delayed restoration is skipped.
218
238
  */
219
239
  private userInterrupted;
240
+ /** Pending destination anchor awaiting data readiness or DOM rendering. */
241
+ private pendingDestination;
242
+ /** Tracks the last fragment processed from route fragment synchronization. */
243
+ private lastProcessedFragment;
220
244
  private target?;
221
245
  private timer?;
246
+ /** Active DOM mutation observer used to retry pending scroll destinations once target anchor elements are inserted. */
247
+ private retryObserver?;
248
+ /** Timeout timer bounding the lifetime of destination retries to prevent memory leaks from non-existent anchors. */
249
+ private retryTimeoutTimer?;
222
250
  constructor();
251
+ /**
252
+ * Determines whether the active router navigation was initiated internally by scroll-spy.
253
+ */
254
+ private isInternalNavigation;
223
255
  /**
224
256
  * Explicitly scrolls the viewport to the target anchor identifier.
225
257
  *
@@ -232,13 +264,53 @@ export declare class MagicScrollDirective {
232
264
  * with the target anchor, overriding any in-flight debounce delays.
233
265
  */
234
266
  scrollTo(anchor: string, options?: MagicScrollToOptions): boolean;
267
+ /**
268
+ * Attempts to execute navigation to the pending destination anchor.
269
+ *
270
+ * @param behavior - The scroll animation behavior to apply (`instant` or `smooth`).
271
+ * @returns `true` if the target anchor element was found in the DOM and scrolling was initiated; `false` otherwise.
272
+ *
273
+ * @remarks
274
+ * If execution succeeds, `pendingDestination` is cleared and active retry observers/timers are canceled.
275
+ * If target elements are not yet present in the DOM (e.g. data is ready before DOM elements render),
276
+ * `pendingDestination` remains queued so subsequent DOM updates can retry.
277
+ */
278
+ private tryExecutePendingDestination;
279
+ /**
280
+ * Schedules a bounded retry mechanism observing DOM additions and attribute modifications
281
+ * within the directive host to re-attempt scrolling when the target anchor element is rendered.
282
+ *
283
+ * @remarks
284
+ * Asynchronous rendering cycles (such as Angular `@for` loops, `@if` blocks, or asynchronous child components)
285
+ * often resolve data/signals (`scrollSource`) before child DOM anchor nodes are attached to the document.
286
+ *
287
+ * To prevent dropped navigations without polling or manual timeouts, a `MutationObserver` watches host mutations
288
+ * for child node insertions and anchor attribute modifications (`id`, `data-scroll-anchor`). When DOM mutations
289
+ * occur and the scroll source is ready, destination execution is re-attempted.
290
+ *
291
+ * A bounded 1000ms safety timeout safeguards against indefinite observation if a non-existent anchor ID was requested.
292
+ */
293
+ private scheduleDestinationRetry;
294
+ /**
295
+ * Disconnects the DOM mutation observer and clears active retry timers.
296
+ *
297
+ * @remarks
298
+ * Invoked whenever destination execution succeeds, a user interrupts navigation (via scroll, touch, or keyboard),
299
+ * an explicit navigation supersedes the pending target, or the directive is destroyed.
300
+ */
301
+ private cancelDestinationRetry;
235
302
  /**
236
303
  * Queries and returns all eligible scroll anchor elements belonging strictly to this directive instance.
237
304
  *
238
305
  * @remarks
239
- * Anchors must possess a non-empty `id` and either match the configured `anchorPrefix` or contain
240
- * the `data-scroll-anchor` attribute. The `closest('[magicScroll]') === this.host` check prevents
241
- * nested or parent `magicScroll` directive instances from capturing child anchors across boundaries.
306
+ * Anchors must possess a non-empty `id` and satisfy the configured selection strategy:
307
+ * - In `'explicit'` mode: Only elements marked with the `data-scroll-anchor` attribute participate,
308
+ * ignoring prefix rules and excluding generated component/form IDs.
309
+ * - In `'all'` mode (default): Elements matching any configured `anchorPrefixes` OR bearing
310
+ * the `data-scroll-anchor` attribute participate.
311
+ *
312
+ * The `closest('[magicScroll]') === this.host` check guarantees that nested child regions
313
+ * retain strict isolation over their own anchors and do not leak across region boundaries.
242
314
  */
243
315
  private anchors;
244
316
  /**
@@ -281,12 +353,12 @@ export declare class MagicScrollDirective {
281
353
  private detachListeners;
282
354
  /**
283
355
  * Intercepts manual user gestures (`wheel`, `touchstart`, `touchmove`) to flag user interaction
284
- * and cancel delayed initial restorations prior to data readiness.
356
+ * and cancel delayed restorations prior to data readiness.
285
357
  */
286
358
  private readonly onUserIntent;
287
359
  /**
288
360
  * Intercepts navigation keyboard events (`ArrowUp`, `ArrowDown`, `PageUp`, `PageDown`, `Home`, `End`, `Space`).
289
- * Cancels pending initial restoration when navigation occurs within the relevant scroll context
361
+ * Cancels pending restoration when navigation occurs within the relevant scroll context
290
362
  * while safely ignoring interactions on editable input elements.
291
363
  */
292
364
  private readonly onKeyDown;
@@ -330,4 +402,4 @@ export declare class ScrollAnchorDirective {
330
402
  };
331
403
  }, {}, never, never, true, never>;
332
404
  }
333
- export type { MagicScrollOptions, MagicScrollToOptions, ScrollDataSource, ScrollResource, ScrollSource };
405
+ export type { AnchorSelectionMode, MagicScrollOptions, MagicScrollToOptions, ScrollDataSource, ScrollResource, ScrollSource };