@devzwo/ngx-magic-scroll 0.0.0-stage → 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,33 @@
1
+ Copyright 2026 devZWO GmbH
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8
+
9
+ ------------------------------------------------------------------------
10
+ Third-party code: internal helpers adapted from ngxtension 7.3.1
11
+ See THIRD_PARTY_NOTICES.txt for provenance and modifications.
12
+
13
+ MIT License
14
+
15
+ Copyright (c) 2023 Chau Tran
16
+
17
+ Permission is hereby granted, free of charge, to any person obtaining a copy
18
+ of this software and associated documentation files (the "Software"), to deal
19
+ in the Software without restriction, including without limitation the rights
20
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21
+ copies of the Software, and to permit persons to whom the Software is
22
+ furnished to do so, subject to the following conditions:
23
+
24
+ The above copyright notice and this permission notice shall be included in all
25
+ copies or substantial portions of the Software.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,124 @@
1
- # Temporary Holding Version
1
+ # @devzwo/ngx-magic-scroll
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![Angular](https://img.shields.io/badge/Angular-22+-DD0031?style=flat-square&logo=angular)](https://angular.dev)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/devZWO/ngx-magic-scroll/ci.yml?style=flat-square)](https://github.com/devZWO/ngx-signal-schema/actions/workflows/ci.yml)
5
+ [![codecov](https://img.shields.io/codecov/c/github/devzwo/ngx-magic-scroll?style=flat-square)](https://codecov.io/github/devZWO/ngx-magic-scroll)
6
+ [![NPM Version](https://img.shields.io/npm/v/@devzwo/ngx-magic-scroll?style=flat-square)](https://npmjs.org/package/@devzwo/ngx-magic-scroll)
7
+ [![License](https://img.shields.io/npm/l/%40devzwo%2Fngx-magic-scroll?style=flat-square)](https://github.com/devZWO/ngx-magic-scroll/blob/main/LICENSE)
8
+
9
+ Anchor scrolling, URL synchronization and position restoration for Angular.
10
+
11
+ **Pre-release:** the API is still under review. Requires Angular 22.2 and RxJS 7.8. See `package.json` for precise ranges.
12
+
13
+ ## Minimal usage
14
+
15
+ Import `MagicScrollDirective` in your standalone component. Angular Router must be provided by the application.
16
+
17
+ ```html
18
+ <div magicScroll>
19
+ <section id="project-1">...</section>
20
+ <section id="project-2">...</section>
21
+ </div>
22
+ ```
23
+
24
+ All descendant IDs participate by default. The directive finds the scroll container automatically, including a scrollable host or the document. It synchronizes the URL fragment while scrolling and restores an initial fragment once after rendering. This is enough for static content or data already loaded by a router resolver.
25
+
26
+ For asynchronous content, pass the source directly:
27
+
28
+ ```ts
29
+ readonly projects = rxResource({ stream: () => this.api.loadProjects() });
30
+ ```
31
+
32
+ ```html
33
+ <div magicScroll [scrollSource]="projects">
34
+ @for (project of projects.value(); track project.id) {
35
+ <section [id]="'project-' + project.id">...</section>
36
+ }
37
+ </div>
38
+ ```
39
+
40
+ The application renders its loading/error states; when a resource can fail, guard `value()` with `hasValue()` in the template. The facade handles this guard internally for scroll readiness.
41
+
42
+ ## Defaults and overrides
43
+
44
+ Register defaults in `app.config.ts` or a component's providers:
45
+
46
+ ```ts
47
+ provideMagicScroll({
48
+ anchorPrefix: 'project-',
49
+ headerSelector: '.app-header',
50
+ headerOffset: 16,
51
+ behavior: { restoration: 'instant', interaction: 'smooth' },
52
+ });
53
+ ```
54
+
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
+
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
+
59
+ ```html
60
+ <div
61
+ magicScroll
62
+ #scroll="magicScroll"
63
+ [scrollSource]="projects"
64
+ [scrollOptions]="{ headerOffset: 24 }"
65
+ >
66
+ ...
67
+ </div>
68
+ <button (click)="scroll.scrollTo('project-2')">Project 2</button>
69
+ ```
70
+
71
+ `scrollTo()` is a deliberate user action and returns whether scrolling was executed. Per-call `behavior`, `topOffset` (the full offset) and `ignoreWhenInView` override the region defaults. `scroll.activeAnchor()` exposes the current fragment for navigation highlighting; `scroll.headerHeight()` and `scroll.offset()` can position a sticky companion panel.
72
+
73
+ 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
+ ## Optional explicit anchors
76
+
77
+ No marker is needed for normal IDs. An explicit anchor participates even outside the configured prefix:
78
+
79
+ ```html
80
+ <section id="overview" scrollAnchor>...</section>
81
+ <section [scrollAnchor]="'project-' + project.id">...</section>
82
+ ```
83
+
84
+ The second form sets the ID as well. Targets are restricted to their own `magicScroll` region; nested regions own their own anchors. IDs must be unique in the document.
85
+
86
+ ## Supported data sources
87
+
88
+ `scrollSource` accepts signals (including computed, writable, input and model signals), Angular `resource`/`rxResource`, a signal containing a resource, and plain input values. For signal forms, pass the field's value signal, such as `form().value`; no Angular Forms dependency is needed.
89
+
90
+ A supplied source waits while it is loading or its data is `null`/`undefined`. Empty arrays, `0`, `false` and empty strings are ready values. Omitting the input means static content; explicitly binding an initially undefined value waits for data. Once the first ready render has been handled, a missing target is not retried as a second initial restoration.
91
+
92
+ The neutral `ScrollDataSource` contract remains available:
93
+
94
+ ```ts
95
+ readonly source = { data: this.items, isLoading: this.loading };
96
+ ```
97
+
98
+ Reactive getters must read signals. TanStack query results can satisfy this contract without a package dependency. Supabase responses can be stored in data/loading signals and exposed with the same contract. Fetching, errors, pagination and caching stay with the application; a bare Supabase client or Promise is not a loading-state adapter.
99
+
100
+ ## Public API and migration
101
+
102
+ The package exports `MagicScrollDirective`, `ScrollAnchorDirective` and `provideMagicScroll`,
103
+ plus the consumer-facing types `MagicScrollOptions`, `MagicScrollToOptions`, `ScrollSource`,
104
+ `ScrollResource` and `ScrollDataSource`. Low-level services, automatic directives, configuration
105
+ internals and reactive helpers are implementation details and cannot be imported from the package.
106
+
107
+ Replace the former combination of scroll-spy, first-data, data-change and resize directives with
108
+ `magicScroll`. Pass the resource instead of a resource adapter, move options to
109
+ `provideMagicScroll`/`scrollOptions`, and use `scroll.scrollTo()` instead of locating a scroll
110
+ container manually. The existing scroll implementation remains internal to the facade.
111
+
112
+ ## Demo and verification
113
+
114
+ Four routed examples demonstrate document synchronization, asynchronous navigation restoration, Material `mat-drawer-container` with pagination, and master–detail document scrolling with a measured sticky header. Options are presented inside these demos.
115
+
116
+ `pnpm test:ci` enforces 100% statements, branches, functions and lines per library implementation file. The existing 25 E2E scenarios remain unchanged after the migration to the facade and pass in Chromium, Firefox and WebKit (75 runs).
117
+
118
+ [Documentation and API](https://devzwo.github.io/ngx-magic-scroll/) · [Source and issues](https://github.com/devZWO/ngx-magic-scroll)
119
+
120
+ MIT, copyright 2026 devZWO GmbH.
121
+
122
+ Two internal helpers are adapted from ngxtension 7.3.1 under the MIT license. See [THIRD_PARTY_NOTICES.txt](./THIRD_PARTY_NOTICES.txt) for their origin, local changes and original license.
123
+
124
+ Production builds retain the original MIT notice in the JavaScript bundle for license extraction. Applications redistributing this code must retain its copyright and license notices, including when minifying or rebundling it.
@@ -0,0 +1,38 @@
1
+ Third-party code notices
2
+
3
+ ngxtension 7.3.1
4
+ Repository: https://github.com/ngxtension/ngxtension-platform
5
+ Upstream commit: ac24352d2b2a4502faf2ff1fc54c241316093c65
6
+
7
+ Local file: src/lib/effects/effect-once-if.ts
8
+ Source: https://github.com/ngxtension/ngxtension-platform/blob/ac24352d2b2a4502faf2ff1fc54c241316093c65/libs/ngxtension/effect-once-if/src/effect-once-if.ts
9
+ Changes: Narrowed to a boolean predicate, a parameterless action and an explicit injector; removed generic options and injector assertion.
10
+
11
+ Local file: src/lib/navigation/inject-route-fragment.ts
12
+ Source: https://github.com/ngxtension/ngxtension-platform/blob/ac24352d2b2a4502faf2ff1fc54c241316093c65/libs/ngxtension/inject-route-fragment/src/inject-route-fragment.ts
13
+ Changes: Removed parsing, transformations, defaults and custom injector options; retained snapshot initialization and Angular-managed subscription cleanup.
14
+
15
+ These adapted helpers are maintained locally. When upgrading Angular, review
16
+ the corresponding upstream helpers for relevant fixes. No ngxtension API is exported.
17
+
18
+ MIT License
19
+
20
+ Copyright (c) 2023 Chau Tran
21
+
22
+ Permission is hereby granted, free of charge, to any person obtaining a copy
23
+ of this software and associated documentation files (the "Software"), to deal
24
+ in the Software without restriction, including without limitation the rights
25
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
26
+ copies of the Software, and to permit persons to whom the Software is
27
+ furnished to do so, subject to the following conditions:
28
+
29
+ The above copyright notice and this permission notice shall be included in all
30
+ copies or substantial portions of the Software.
31
+
32
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38
+ SOFTWARE.
@@ -0,0 +1,476 @@
1
+ /*!
2
+ * @license
3
+ * MIT License
4
+ *
5
+ * Copyright (c) 2023 Chau Tran
6
+ *
7
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ * of this software and associated documentation files (the "Software"), to deal
9
+ * in the Software without restriction, including without limitation the rights
10
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ * copies of the Software, and to permit persons to whom the Software is
12
+ * furnished to do so, subject to the following conditions:
13
+ *
14
+ * The above copyright notice and this permission notice shall be included in all
15
+ * copies or substantial portions of the Software.
16
+ *
17
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ * SOFTWARE.
24
+ */
25
+ import { DOCUMENT } from "@angular/common";
26
+ import * as i0 from "@angular/core";
27
+ import { DestroyRef, Directive, ElementRef, Injectable, InjectionToken, afterNextRender, afterRenderEffect, computed, effect, inject, input, isSignal, linkedSignal, signal, untracked } from "@angular/core";
28
+ import { ActivatedRoute, Router } from "@angular/router";
29
+ import { toSignal } from "@angular/core/rxjs-interop";
30
+ function linkedRouteFragment() {
31
+ const route = inject(ActivatedRoute);
32
+ const router = inject(Router);
33
+ const fragment = toSignal(route.fragment);
34
+ const actualFragment = linkedSignal(() => fragment(), ...ngDevMode ? [{ debugName: "actualFragment" }] : /* istanbul ignore next */ []);
35
+ effect(() => {
36
+ const newFragment = actualFragment();
37
+ if (route.snapshot.fragment !== newFragment) {
38
+ const tree = router.createUrlTree([], {
39
+ relativeTo: route,
40
+ fragment: newFragment ?? void 0,
41
+ queryParamsHandling: "preserve"
42
+ });
43
+ router.navigateByUrl(tree, { replaceUrl: true }).catch((error) => {
44
+ console.log(`Cant set fragment ${newFragment}`, error);
45
+ });
46
+ }
47
+ });
48
+ return actualFragment;
49
+ }
50
+ function injectScrollableParentElement(hostElement) {
51
+ function findScrollableParent(el) {
52
+ let current = el.parentElement ?? void 0;
53
+ while (current) {
54
+ const overflowY = getComputedStyle(current).overflowY;
55
+ if (overflowY === "auto" || overflowY === "scroll") return current;
56
+ current = current.parentElement ?? void 0;
57
+ }
58
+ }
59
+ return findScrollableParent(hostElement ?? inject(ElementRef).nativeElement);
60
+ }
61
+ function injectedHeaderHeight(headerSelector, document) {
62
+ const boundingBox = document.querySelector(headerSelector)?.getBoundingClientRect();
63
+ if (!boundingBox) {
64
+ console.warn("No boundingBox found for headerSelector: ", headerSelector);
65
+ return 0;
66
+ }
67
+ return Math.abs(boundingBox.height);
68
+ }
69
+ var NearestAnchorProvider = class NearestAnchorProvider {
70
+ document = inject(DOCUMENT);
71
+ getNearestAnchor(options) {
72
+ const host = options?.hostElement ?? this.document;
73
+ const selector = options?.selector ?? "[id]";
74
+ const offset = (options?.headerOffset ?? 0) + (options?.headerSelector ? injectedHeaderHeight(options.headerSelector, this.document) : 0);
75
+ const anchors = options?.anchors ?? Array.from(host.querySelectorAll(selector));
76
+ const scrollable = options?.scrollable ?? (options?.hostElement ? injectScrollableParentElement(options.hostElement) : void 0);
77
+ const top = (scrollable === this.document.documentElement || scrollable === this.document.body ? 0 : scrollable?.getBoundingClientRect().top ?? 0) + offset;
78
+ return anchors.filter((el) => el.getBoundingClientRect().top >= top - 1).reduce((closest, el) => {
79
+ const offset = Math.abs(el.getBoundingClientRect().top);
80
+ return offset < closest.offset ? {
81
+ el,
82
+ offset
83
+ } : closest;
84
+ }, {
85
+ el: null,
86
+ offset: Number.POSITIVE_INFINITY
87
+ }).el?.id;
88
+ }
89
+ static ɵfac = i0.ɵɵngDeclareFactory({
90
+ minVersion: "12.0.0",
91
+ version: "22.2.1",
92
+ ngImport: i0,
93
+ type: NearestAnchorProvider,
94
+ deps: [],
95
+ target: i0.ɵɵFactoryTarget.Injectable
96
+ });
97
+ static ɵprov = i0.ɵɵngDeclareInjectable({
98
+ minVersion: "12.0.0",
99
+ version: "22.2.1",
100
+ ngImport: i0,
101
+ type: NearestAnchorProvider
102
+ });
103
+ };
104
+ i0.ɵɵngDeclareClassMetadata({
105
+ minVersion: "12.0.0",
106
+ version: "22.2.1",
107
+ ngImport: i0,
108
+ type: NearestAnchorProvider,
109
+ decorators: [{ type: Injectable }]
110
+ });
111
+ var ScrollService = class ScrollService {
112
+ document = inject(DOCUMENT);
113
+ TOP_OFFSET = 20;
114
+ scroll(anchor, options) {
115
+ const elementId = anchor.startsWith("#") ? anchor.slice(1) : anchor;
116
+ const el = this.document.getElementById(elementId);
117
+ if (!el) return false;
118
+ const scrollable = options?.scrollable ?? this.document.scrollingElement ?? this.document.documentElement;
119
+ const behavior = options?.behavior ?? "instant";
120
+ const ignoreWhenInView = options?.ignoreWhenInView ?? "none";
121
+ const topOffset = options?.topOffset ?? this.TOP_OFFSET;
122
+ const rect = el.getBoundingClientRect();
123
+ const isDocument = scrollable === this.document.documentElement || scrollable === this.document.body;
124
+ const containerTop = isDocument ? 0 : scrollable.getBoundingClientRect().top;
125
+ const viewportHeight = isDocument ? this.document.documentElement.clientHeight : scrollable.clientHeight;
126
+ const isTopInView = rect.top >= containerTop + topOffset - 1 && rect.top < containerTop + viewportHeight;
127
+ const isBottomInView = rect.bottom <= containerTop + viewportHeight && rect.bottom > containerTop;
128
+ if (ignoreWhenInView == "top" && isTopInView || ignoreWhenInView == "full" && isTopInView && isBottomInView || ignoreWhenInView == "always" && (isTopInView || isBottomInView)) return false;
129
+ scrollable.scrollTo({
130
+ top: scrollable.scrollTop + rect.top - containerTop - topOffset,
131
+ behavior
132
+ });
133
+ return true;
134
+ }
135
+ static ɵfac = i0.ɵɵngDeclareFactory({
136
+ minVersion: "12.0.0",
137
+ version: "22.2.1",
138
+ ngImport: i0,
139
+ type: ScrollService,
140
+ deps: [],
141
+ target: i0.ɵɵFactoryTarget.Injectable
142
+ });
143
+ static ɵprov = i0.ɵɵngDeclareInjectable({
144
+ minVersion: "12.0.0",
145
+ version: "22.2.1",
146
+ ngImport: i0,
147
+ type: ScrollService,
148
+ providedIn: "root"
149
+ });
150
+ };
151
+ i0.ɵɵngDeclareClassMetadata({
152
+ minVersion: "12.0.0",
153
+ version: "22.2.1",
154
+ ngImport: i0,
155
+ type: ScrollService,
156
+ decorators: [{
157
+ type: Injectable,
158
+ args: [{ providedIn: "root" }]
159
+ }]
160
+ });
161
+ const defaults = {
162
+ anchorPrefix: "",
163
+ headerSelector: "",
164
+ headerOffset: 0,
165
+ behavior: {
166
+ restoration: "instant",
167
+ interaction: "smooth"
168
+ },
169
+ debounceTime: 500,
170
+ ignoreWhenInView: "none",
171
+ restoreOnDataChange: true,
172
+ preserveOnResize: true
173
+ };
174
+ function resolveMagicScrollOptions(parent, options) {
175
+ return {
176
+ anchorPrefix: options.anchorPrefix ?? parent.anchorPrefix,
177
+ headerSelector: options.headerSelector ?? parent.headerSelector,
178
+ headerOffset: options.headerOffset ?? parent.headerOffset,
179
+ behavior: {
180
+ restoration: options.behavior?.restoration ?? parent.behavior.restoration,
181
+ interaction: options.behavior?.interaction ?? parent.behavior.interaction
182
+ },
183
+ debounceTime: options.debounceTime ?? parent.debounceTime,
184
+ ignoreWhenInView: options.ignoreWhenInView ?? parent.ignoreWhenInView,
185
+ restoreOnDataChange: options.restoreOnDataChange ?? parent.restoreOnDataChange,
186
+ preserveOnResize: options.preserveOnResize ?? parent.preserveOnResize
187
+ };
188
+ }
189
+ const MAGIC_SCROLL_OPTIONS = new InjectionToken("Magic scroll defaults", {
190
+ providedIn: "root",
191
+ factory: () => defaults
192
+ });
193
+ function provideMagicScroll(options = {}) {
194
+ return {
195
+ provide: MAGIC_SCROLL_OPTIONS,
196
+ useFactory: () => resolveMagicScrollOptions(inject(MAGIC_SCROLL_OPTIONS, {
197
+ skipSelf: true,
198
+ optional: true
199
+ }) ?? defaults, options)
200
+ };
201
+ }
202
+ const NO_SCROLL_SOURCE = Symbol("Static, already rendered data");
203
+ function readScrollSource(source) {
204
+ if (source === NO_SCROLL_SOURCE) return {
205
+ ready: true,
206
+ data: source
207
+ };
208
+ let data = source;
209
+ while (isSignal(data)) data = data();
210
+ if (data !== null && typeof data === "object") {
211
+ const candidate = data;
212
+ if (typeof candidate.isLoading === "function") {
213
+ if (candidate.isLoading()) return {
214
+ ready: false,
215
+ data: void 0
216
+ };
217
+ if ("value" in data && typeof data.value === "function") {
218
+ const resource = data;
219
+ data = resource.hasValue && !resource.hasValue() ? void 0 : resource.value();
220
+ } else if (typeof candidate.data === "function") data = candidate.data();
221
+ }
222
+ }
223
+ return {
224
+ ready: data !== void 0 && data !== null,
225
+ data
226
+ };
227
+ }
228
+ var MagicScrollDirective = class MagicScrollDirective {
229
+ scrollSource = input(NO_SCROLL_SOURCE, {
230
+ ...ngDevMode ? { debugName: "scrollSource" } : /* istanbul ignore next */ {},
231
+ transform: (value) => value
232
+ });
233
+ scrollOptions = input({}, ...ngDevMode ? [{ debugName: "scrollOptions" }] : /* istanbul ignore next */ []);
234
+ defaults = inject(MAGIC_SCROLL_OPTIONS);
235
+ options = computed(() => resolveMagicScrollOptions(this.defaults, this.scrollOptions()), ...ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []);
236
+ document = inject(DOCUMENT);
237
+ host = inject(ElementRef).nativeElement;
238
+ service = inject(ScrollService);
239
+ nearest = inject(NearestAnchorProvider);
240
+ fragment = linkedRouteFragment();
241
+ initialAnchor = inject(ActivatedRoute).snapshot.fragment;
242
+ measuredHeaderHeight = signal(0, ...ngDevMode ? [{ debugName: "measuredHeaderHeight" }] : /* istanbul ignore next */ []);
243
+ activeAnchor = this.fragment.asReadonly();
244
+ headerHeight = this.measuredHeaderHeight.asReadonly();
245
+ offset = computed(() => this.headerHeight() + this.options().headerOffset, ...ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []);
246
+ scrollRevision = signal(0, ...ngDevMode ? [{ debugName: "scrollRevision" }] : /* istanbul ignore next */ []);
247
+ initialized = false;
248
+ target;
249
+ timer;
250
+ constructor() {
251
+ const destroyRef = inject(DestroyRef);
252
+ afterNextRender(() => {
253
+ this.refreshTarget();
254
+ this.document.defaultView.addEventListener("resize", this.onResize);
255
+ });
256
+ destroyRef.onDestroy(() => {
257
+ this.target?.removeEventListener("scroll", this.onScroll);
258
+ this.document.defaultView?.removeEventListener("resize", this.onResize);
259
+ clearTimeout(this.timer);
260
+ });
261
+ afterRenderEffect((onCleanup) => {
262
+ const selector = this.options().headerSelector;
263
+ const header = selector ? this.document.querySelector(selector) : null;
264
+ const measure = () => {
265
+ const height = header?.getBoundingClientRect().height ?? 0;
266
+ if (height !== this.headerHeight()) {
267
+ this.measuredHeaderHeight.set(height);
268
+ if (this.options().preserveOnResize) this.preserve();
269
+ }
270
+ };
271
+ untracked(measure);
272
+ if (header) {
273
+ const observer = new ResizeObserver(measure);
274
+ observer.observe(header);
275
+ onCleanup(() => observer.disconnect());
276
+ }
277
+ });
278
+ afterRenderEffect(() => {
279
+ const source = this.scrollSource();
280
+ if (!readScrollSource(source).ready) return;
281
+ untracked(() => {
282
+ if (!this.initialized) {
283
+ this.initialized = true;
284
+ this.execute(this.initialAnchor, { behavior: this.options().behavior.restoration });
285
+ } else if (source !== NO_SCROLL_SOURCE && this.options().restoreOnDataChange) this.preserve("top");
286
+ });
287
+ });
288
+ effect(() => {
289
+ if (!this.scrollRevision()) return;
290
+ const anchor = this.nearest.getNearestAnchor({
291
+ hostElement: this.host,
292
+ anchors: this.anchors(),
293
+ scrollable: this.scrollable(),
294
+ headerOffset: this.offset()
295
+ });
296
+ if (anchor) this.fragment.set(anchor);
297
+ });
298
+ }
299
+ scrollTo(anchor, options = {}) {
300
+ const executed = this.execute(anchor, {
301
+ behavior: this.options().behavior.interaction,
302
+ ignoreWhenInView: this.options().ignoreWhenInView,
303
+ ...options
304
+ });
305
+ if (executed) this.fragment.set(anchor.replace(/^#/, ""));
306
+ return executed;
307
+ }
308
+ anchors() {
309
+ const prefix = this.options().anchorPrefix;
310
+ 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);
311
+ }
312
+ scrollable() {
313
+ const overflow = getComputedStyle(this.host).overflowY;
314
+ const container = overflow === "auto" || overflow === "scroll" ? this.host : injectScrollableParentElement(this.host);
315
+ return container === this.document.documentElement || container === this.document.body ? void 0 : container;
316
+ }
317
+ execute(anchor, options) {
318
+ if (!anchor || !this.anchors().some((el) => el.id === anchor.replace(/^#/, ""))) return false;
319
+ return this.service.scroll(anchor, {
320
+ scrollable: this.scrollable(),
321
+ topOffset: this.offset(),
322
+ ...options
323
+ });
324
+ }
325
+ preserve(ignoreWhenInView = "none") {
326
+ if (this.initialized) this.execute(this.fragment(), {
327
+ behavior: "instant",
328
+ ignoreWhenInView
329
+ });
330
+ }
331
+ refreshTarget() {
332
+ const target = this.scrollable() ?? this.document;
333
+ if (target !== this.target) {
334
+ this.target?.removeEventListener("scroll", this.onScroll);
335
+ this.target = target;
336
+ target.addEventListener("scroll", this.onScroll);
337
+ }
338
+ }
339
+ onScroll = () => {
340
+ if (!this.initialized) return;
341
+ clearTimeout(this.timer);
342
+ this.timer = setTimeout(() => this.scrollRevision.update((revision) => revision + 1), this.options().debounceTime);
343
+ };
344
+ onResize = () => {
345
+ this.refreshTarget();
346
+ if (this.options().preserveOnResize) this.preserve();
347
+ };
348
+ static ɵfac = i0.ɵɵngDeclareFactory({
349
+ minVersion: "12.0.0",
350
+ version: "22.2.1",
351
+ ngImport: i0,
352
+ type: MagicScrollDirective,
353
+ deps: [],
354
+ target: i0.ɵɵFactoryTarget.Directive
355
+ });
356
+ static ɵdir = i0.ɵɵngDeclareDirective({
357
+ minVersion: "17.1.0",
358
+ version: "22.2.1",
359
+ type: MagicScrollDirective,
360
+ isStandalone: true,
361
+ selector: "[magicScroll]",
362
+ inputs: {
363
+ scrollSource: {
364
+ classPropertyName: "scrollSource",
365
+ publicName: "scrollSource",
366
+ isSignal: true,
367
+ isRequired: false,
368
+ transformFunction: null
369
+ },
370
+ scrollOptions: {
371
+ classPropertyName: "scrollOptions",
372
+ publicName: "scrollOptions",
373
+ isSignal: true,
374
+ isRequired: false,
375
+ transformFunction: null
376
+ }
377
+ },
378
+ host: { properties: { "attr.magicScroll": "\"\"" } },
379
+ providers: [NearestAnchorProvider],
380
+ exportAs: ["magicScroll"],
381
+ ngImport: i0
382
+ });
383
+ };
384
+ i0.ɵɵngDeclareClassMetadata({
385
+ minVersion: "12.0.0",
386
+ version: "22.2.1",
387
+ ngImport: i0,
388
+ type: MagicScrollDirective,
389
+ decorators: [{
390
+ type: Directive,
391
+ args: [{
392
+ selector: "[magicScroll]",
393
+ exportAs: "magicScroll",
394
+ providers: [NearestAnchorProvider],
395
+ host: { "[attr.magicScroll]": "\"\"" }
396
+ }]
397
+ }],
398
+ ctorParameters: () => [],
399
+ propDecorators: {
400
+ scrollSource: [{
401
+ type: i0.Input,
402
+ args: [{
403
+ isSignal: true,
404
+ alias: "scrollSource",
405
+ required: false
406
+ }]
407
+ }],
408
+ scrollOptions: [{
409
+ type: i0.Input,
410
+ args: [{
411
+ isSignal: true,
412
+ alias: "scrollOptions",
413
+ required: false
414
+ }]
415
+ }]
416
+ }
417
+ });
418
+ var ScrollAnchorDirective = class ScrollAnchorDirective {
419
+ scrollAnchor = input("", ...ngDevMode ? [{ debugName: "scrollAnchor" }] : /* istanbul ignore next */ []);
420
+ host = inject(ElementRef);
421
+ constructor() {
422
+ effect(() => {
423
+ const id = this.scrollAnchor();
424
+ if (id) this.host.nativeElement.id = id;
425
+ });
426
+ }
427
+ static ɵfac = i0.ɵɵngDeclareFactory({
428
+ minVersion: "12.0.0",
429
+ version: "22.2.1",
430
+ ngImport: i0,
431
+ type: ScrollAnchorDirective,
432
+ deps: [],
433
+ target: i0.ɵɵFactoryTarget.Directive
434
+ });
435
+ static ɵdir = i0.ɵɵngDeclareDirective({
436
+ minVersion: "17.1.0",
437
+ version: "22.2.1",
438
+ type: ScrollAnchorDirective,
439
+ isStandalone: true,
440
+ selector: "[scrollAnchor]",
441
+ inputs: { scrollAnchor: {
442
+ classPropertyName: "scrollAnchor",
443
+ publicName: "scrollAnchor",
444
+ isSignal: true,
445
+ isRequired: false,
446
+ transformFunction: null
447
+ } },
448
+ host: { properties: { "attr.data-scroll-anchor": "\"\"" } },
449
+ ngImport: i0
450
+ });
451
+ };
452
+ i0.ɵɵngDeclareClassMetadata({
453
+ minVersion: "12.0.0",
454
+ version: "22.2.1",
455
+ ngImport: i0,
456
+ type: ScrollAnchorDirective,
457
+ decorators: [{
458
+ type: Directive,
459
+ args: [{
460
+ selector: "[scrollAnchor]",
461
+ host: { "[attr.data-scroll-anchor]": "\"\"" }
462
+ }]
463
+ }],
464
+ ctorParameters: () => [],
465
+ propDecorators: { scrollAnchor: [{
466
+ type: i0.Input,
467
+ args: [{
468
+ isSignal: true,
469
+ alias: "scrollAnchor",
470
+ required: false
471
+ }]
472
+ }] }
473
+ });
474
+ export { MagicScrollDirective, ScrollAnchorDirective, provideMagicScroll };
475
+
476
+ //# sourceMappingURL=devzwo-ngx-magic-scroll.mjs.map
@@ -0,0 +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\n/**\n * Finds the anchor nearest the top edge of the visible scroll area.\n *\n * Supports scroll-spy behavior to identify the current section while scrolling and\n * synchronize the URL fragment.\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 anchor nearest the visible top edge.\n *\n * 1. Finds matching elements using selector within hostElement, or within document.\n * 2. Excludes elements whose top edge lies above the configured offset.\n * 3. Selects the remaining element nearest the top edge.\n *\n * @param options - Host element, selector, scroll container and header-offset settings.\n * @returns The nearest 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 * });\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 const scrollable =\n options?.scrollable ??\n (options?.hostElement ? injectScrollableParentElement(options.hostElement) : undefined);\n const isDocument =\n scrollable === this.document.documentElement || scrollable === this.document.body;\n const top = (isDocument ? 0 : (scrollable?.getBoundingClientRect().top ?? 0)) + offset;\n // Browser scroll positions may round fractional header heights to whole pixels.\n const visibleAnchors = anchors.filter((el) => el.getBoundingClientRect().top >= top - 1);\n\n // Iterates through all anchors and calculates their distance to the current scroll position (in pixels)\n const closest = visibleAnchors.reduce(\n (closest, el) => {\n // Retrieves the distance of the element to the top edge of the viewport\n const offset = Math.abs(el.getBoundingClientRect().top);\n\n // Compares the current distance with the smallest distance so far\n // and keeps track of the closer element\n return offset < closest.offset\n ? { el, offset } // if closer: replace the previous one\n : closest; // otherwise: keep the previous one\n },\n {\n el: null as HTMLElement | null, // Initial value: no element\n offset: Number.POSITIVE_INFINITY, // Initial value: maximum distance\n },\n );\n\n return closest.el?.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 /** 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 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 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 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/** Per-action overrides; the facade always chooses and scopes the scroll container. */\nexport type MagicScrollToOptions = Pick<\n ScrollOptions,\n 'behavior' | 'topOffset' | 'ignoreWhenInView'\n>;\n\n/**\n * Anchor scrolling, URL sync, initial restoration and layout preservation in one directive.\n * Without scrollSource the initial rendered DOM is restored once (e.g. resolver data).\n * With a source, restoration waits for ready data and later updates preserve the current anchor.\n *\n * @example\n * <div magicScroll #scroll=\"magicScroll\" [scrollSource]=\"projects\">\n * <section id=\"project-1\">...</section>\n * </div>\n * <button (click)=\"scroll.scrollTo('project-1')\">Project 1</button>\n */\n@Directive({\n selector: '[magicScroll]',\n exportAs: 'magicScroll',\n providers: [NearestAnchorProvider],\n host: { '[attr.magicScroll]': '\"\"' },\n})\nexport class MagicScrollDirective {\n readonly scrollSource = input<ScrollSource | typeof NO_SCROLL_SOURCE, ScrollSource>(\n NO_SCROLL_SOURCE,\n { transform: (value) => value },\n );\n readonly scrollOptions = input<MagicScrollOptions>({});\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 fragment = linkedRouteFragment();\n private readonly initialAnchor = inject(ActivatedRoute).snapshot.fragment;\n private readonly measuredHeaderHeight = signal(0);\n /** Current URL anchor, suitable for active-navigation highlighting. */\n readonly activeAnchor = this.fragment.asReadonly();\n /** Measured header height, useful for sticky companion panels. */\n readonly headerHeight = this.measuredHeaderHeight.asReadonly();\n /** Complete effective top offset, including header height and configured gap. */\n readonly offset = computed(() => this.headerHeight() + this.options().headerOffset);\n private readonly scrollRevision = signal(0);\n private initialized = false;\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.target?.removeEventListener('scroll', this.onScroll);\n this.document.defaultView?.removeEventListener('resize', this.onResize);\n clearTimeout(this.timer);\n });\n\n // Registration order ensures header measurement precedes 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 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 this.execute(this.initialAnchor, { behavior: this.options().behavior.restoration });\n } else if (source !== NO_SCROLL_SOURCE && this.options().restoreOnDataChange) {\n this.preserve('top');\n }\n });\n });\n effect(() => {\n if (!this.scrollRevision()) return;\n const anchor = this.nearest.getNearestAnchor({\n hostElement: this.host,\n anchors: this.anchors(),\n scrollable: this.scrollable(),\n headerOffset: this.offset(),\n });\n if (anchor) this.fragment.set(anchor);\n });\n }\n\n /** Explicit user navigation. Per-call options override region and provider defaults. */\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) this.fragment.set(anchor.replace(/^#/, ''));\n return executed;\n }\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 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 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 private preserve(ignoreWhenInView: ScrollOptions['ignoreWhenInView'] = 'none'): void {\n if (this.initialized) this.execute(this.fragment(), { behavior: 'instant', ignoreWhenInView });\n }\n\n private refreshTarget(): void {\n const target = this.scrollable() ?? this.document;\n if (target !== this.target) {\n this.target?.removeEventListener('scroll', this.onScroll);\n this.target = target;\n target.addEventListener('scroll', this.onScroll);\n }\n }\n\n private readonly onScroll = () => {\n if (!this.initialized) return;\n clearTimeout(this.timer);\n this.timer = setTimeout(\n () => this.scrollRevision.update((revision) => revision + 1),\n this.options().debounceTime,\n );\n };\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":"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"}
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@devzwo/ngx-magic-scroll",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1-rc.1",
4
+ "peerDependencies": {
5
+ "@angular/common": "^22.2.0",
6
+ "@angular/core": "^22.2.0",
7
+ "@angular/router": "^22.2.0",
8
+ "rxjs": "^7.8.0"
9
+ },
10
+ "dependencies": {
11
+ "tslib": "^2.3.0"
12
+ },
13
+ "sideEffects": false,
14
+ "description": "Angular signal utilities for anchor scrolling and navigation state.",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/devZWO/ngx-magic-scroll.git",
19
+ "directory": "projects/ngx-magic-scroll"
20
+ },
21
+ "homepage": "https://devzwo.github.io/ngx-magic-scroll/",
22
+ "bugs": {
23
+ "url": "https://github.com/devZWO/ngx-magic-scroll/issues"
24
+ },
25
+ "keywords": [
26
+ "angular",
27
+ "signals",
28
+ "scroll",
29
+ "navigation"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "module": "fesm2022/devzwo-ngx-magic-scroll.mjs",
35
+ "typings": "types/devzwo-ngx-magic-scroll.d.ts",
36
+ "exports": {
37
+ "./package.json": {
38
+ "default": "./package.json"
39
+ },
40
+ ".": {
41
+ "types": "./types/devzwo-ngx-magic-scroll.d.ts",
42
+ "default": "./fesm2022/devzwo-ngx-magic-scroll.mjs"
43
+ }
44
+ },
45
+ "type": "module"
6
46
  }
@@ -0,0 +1,209 @@
1
+ import * as i0 from "@angular/core";
2
+ import { InjectionToken, Provider, Signal } from "@angular/core";
3
+ /**
4
+ * Options for a scroll operation performed by ScrollService.
5
+ */
6
+ interface ScrollOptions {
7
+ /**
8
+ * Scrollable container. Defaults to the document's scrolling element.
9
+ */
10
+ scrollable?: HTMLElement;
11
+ /**
12
+ * Scroll behavior: 'instant' for an immediate jump, 'smooth' for animation,
13
+ * or 'auto' to follow the browser's CSS scroll behavior. Defaults to 'instant'.
14
+ */
15
+ behavior?: ScrollBehavior;
16
+ /**
17
+ * Visibility condition under which scrolling is skipped:
18
+ * - 'none': Always scroll (default).
19
+ * - 'top': Skip when the element's top edge is visible.
20
+ * - 'full': Skip when the entire element is visible.
21
+ * - 'always': Skip when either the top or bottom edge is visible.
22
+ */
23
+ ignoreWhenInView?: 'none' | 'top' | 'full' | 'always';
24
+ /**
25
+ * Additional top offset for the target, in pixels, for example for a header or padding.
26
+ * Defaults to 20 pixels.
27
+ */
28
+ topOffset?: number;
29
+ }
30
+ /**
31
+ * Central service for scrolling to DOM elements and anchors.
32
+ *
33
+ * Supports explicit scroll containers or document scrolling, configurable header offsets,
34
+ * visibility checks to avoid unnecessary movement, and smooth or instant scrolling.
35
+ */
36
+ declare class ScrollService {
37
+ private readonly document;
38
+ private readonly TOP_OFFSET;
39
+ /**
40
+ * Scrolls to the element identified by its ID.
41
+ *
42
+ * @param anchor - Target HTML element ID, with or without a leading #.
43
+ * @param options - Scroll container, behavior, offset and visibility settings.
44
+ * @returns True when the target exists and scrolling is initiated; otherwise false.
45
+ * @example
46
+ * ```ts
47
+ * // Simple scrolling
48
+ * scrollService.scroll('section-details');
49
+ *
50
+ * // With options
51
+ * scrollService.scroll('card-123', {
52
+ * scrollable: scrollContainerElement,
53
+ * behavior: 'smooth',
54
+ * topOffset: 80,
55
+ * ignoreWhenInView: 'top'
56
+ * });
57
+ * ```
58
+ */
59
+ scroll(anchor: string, options?: ScrollOptions): boolean;
60
+ static ɵfac: i0.ɵɵFactoryDeclaration<ScrollService, never>;
61
+ static ɵprov: i0.ɵɵInjectableDeclaration<any>;
62
+ }
63
+ /** Defaults for an application or overrides for one `magicScroll` region. */
64
+ interface MagicScrollOptions {
65
+ /** All descendant IDs participate when empty. Explicit `scrollAnchor`s also participate. */
66
+ anchorPrefix?: string;
67
+ /** Selector for a fixed/sticky header whose height is measured automatically. */
68
+ headerSelector?: string;
69
+ /** Gap below the header, or the complete top offset when no header is selected. */
70
+ headerOffset?: number;
71
+ /** Restoration and deliberate user navigation have separate defaults. */
72
+ behavior?: {
73
+ restoration?: ScrollBehavior;
74
+ interaction?: ScrollBehavior;
75
+ };
76
+ debounceTime?: number;
77
+ ignoreWhenInView?: ScrollOptions['ignoreWhenInView'];
78
+ /** Keep the current anchor visible after later data updates. Defaults to true. */
79
+ restoreOnDataChange?: boolean;
80
+ /** Preserve the current anchor on window resize and measured header-height changes. */
81
+ preserveOnResize?: boolean;
82
+ }
83
+ interface ResolvedMagicScrollOptions {
84
+ anchorPrefix: string;
85
+ headerSelector: string;
86
+ headerOffset: number;
87
+ behavior: {
88
+ restoration: ScrollBehavior;
89
+ interaction: ScrollBehavior;
90
+ };
91
+ debounceTime: number;
92
+ ignoreWhenInView: NonNullable<ScrollOptions['ignoreWhenInView']>;
93
+ restoreOnDataChange: boolean;
94
+ preserveOnResize: boolean;
95
+ }
96
+ declare function resolveMagicScrollOptions(parent: ResolvedMagicScrollOptions, options: MagicScrollOptions): ResolvedMagicScrollOptions;
97
+ declare const MAGIC_SCROLL_OPTIONS: InjectionToken<ResolvedMagicScrollOptions>;
98
+ /**
99
+ * Register defaults globally or on a component. Child providers inherit unspecified options.
100
+ * Per-region `scrollOptions` and per-call `scrollTo` options take precedence.
101
+ *
102
+ * @example
103
+ * provideMagicScroll({ anchorPrefix: 'project-', behavior: { interaction: 'smooth' } })
104
+ */
105
+ export declare function provideMagicScroll(options?: MagicScrollOptions): Provider;
106
+ /** Reactive getters must read signals so loading and data changes are tracked.
107
+ * rxResource: { data: () => resource.value(), isLoading: resource.isLoading }.
108
+ * TanStack results already satisfy this contract. Supabase results can be stored in signals.
109
+ */
110
+ interface ScrollDataSource {
111
+ readonly data: () => unknown;
112
+ readonly isLoading: () => boolean;
113
+ }
114
+ /** Structural Angular resource contract; no dependency on a data-fetching provider. */
115
+ interface ScrollResource {
116
+ readonly value: () => unknown;
117
+ readonly isLoading: () => boolean;
118
+ readonly hasValue?: () => boolean;
119
+ }
120
+ /**
121
+ * Pass a signal (also computed/input/model signals), a resource/rxResource, or
122
+ * reactive data()/isLoading() getters. Plain input values are supported too.
123
+ * For signal forms pass the field's value signal, e.g. `form().value`.
124
+ * Supabase data and loading signals can use the neutral ScrollDataSource contract.
125
+ */
126
+ type ScrollSource = Signal<unknown> | ScrollResource | ScrollDataSource | object | string | number | boolean | null | undefined;
127
+ declare const NO_SCROLL_SOURCE: unique symbol;
128
+ /** Internal readiness adapter. null/undefined wait; [], 0, false and '' are ready. */
129
+ declare function readScrollSource(source: ScrollSource | typeof NO_SCROLL_SOURCE): {
130
+ ready: boolean;
131
+ data: unknown;
132
+ };
133
+ /** Per-action overrides; the facade always chooses and scopes the scroll container. */
134
+ type MagicScrollToOptions = Pick<ScrollOptions, 'behavior' | 'topOffset' | 'ignoreWhenInView'>;
135
+ /**
136
+ * Anchor scrolling, URL sync, initial restoration and layout preservation in one directive.
137
+ * Without scrollSource the initial rendered DOM is restored once (e.g. resolver data).
138
+ * With a source, restoration waits for ready data and later updates preserve the current anchor.
139
+ *
140
+ * @example
141
+ * <div magicScroll #scroll="magicScroll" [scrollSource]="projects">
142
+ * <section id="project-1">...</section>
143
+ * </div>
144
+ * <button (click)="scroll.scrollTo('project-1')">Project 1</button>
145
+ */
146
+ export declare class MagicScrollDirective {
147
+ readonly scrollSource: import("@angular/core").InputSignalWithTransform<ScrollSource | typeof NO_SCROLL_SOURCE, ScrollSource>;
148
+ readonly scrollOptions: import("@angular/core").InputSignal<MagicScrollOptions>;
149
+ private readonly defaults;
150
+ private readonly options;
151
+ private readonly document;
152
+ private readonly host;
153
+ private readonly service;
154
+ private readonly nearest;
155
+ private readonly fragment;
156
+ private readonly initialAnchor;
157
+ private readonly measuredHeaderHeight;
158
+ /** Current URL anchor, suitable for active-navigation highlighting. */
159
+ readonly activeAnchor: import("@angular/core").Signal<string | null | undefined>;
160
+ /** Measured header height, useful for sticky companion panels. */
161
+ readonly headerHeight: import("@angular/core").Signal<number>;
162
+ /** Complete effective top offset, including header height and configured gap. */
163
+ readonly offset: import("@angular/core").Signal<number>;
164
+ private readonly scrollRevision;
165
+ private initialized;
166
+ private target?;
167
+ private timer?;
168
+ constructor();
169
+ /** Explicit user navigation. Per-call options override region and provider defaults. */
170
+ scrollTo(anchor: string, options?: MagicScrollToOptions): boolean;
171
+ private anchors;
172
+ private scrollable;
173
+ private execute;
174
+ private preserve;
175
+ private refreshTarget;
176
+ private readonly onScroll;
177
+ private readonly onResize;
178
+ static ɵfac: i0.ɵɵFactoryDeclaration<MagicScrollDirective, never>;
179
+ static ɵdir: i0.ɵɵDirectiveDeclaration<MagicScrollDirective, "[magicScroll]", ["magicScroll"], {
180
+ "scrollSource": {
181
+ "alias": "scrollSource";
182
+ "required": false;
183
+ "isSignal": true;
184
+ };
185
+ "scrollOptions": {
186
+ "alias": "scrollOptions";
187
+ "required": false;
188
+ "isSignal": true;
189
+ };
190
+ }, {}, never, never, true, never>;
191
+ }
192
+ /**
193
+ * Optional explicit anchor. Its ID participates even outside the configured prefix.
194
+ * Use `scrollAnchor` alongside an existing id, or `[scrollAnchor]="item.id"` to set it.
195
+ */
196
+ export declare class ScrollAnchorDirective {
197
+ readonly scrollAnchor: import("@angular/core").InputSignal<string>;
198
+ private readonly host;
199
+ constructor();
200
+ static ɵfac: i0.ɵɵFactoryDeclaration<ScrollAnchorDirective, never>;
201
+ static ɵdir: i0.ɵɵDirectiveDeclaration<ScrollAnchorDirective, "[scrollAnchor]", never, {
202
+ "scrollAnchor": {
203
+ "alias": "scrollAnchor";
204
+ "required": false;
205
+ "isSignal": true;
206
+ };
207
+ }, {}, never, never, true, never>;
208
+ }
209
+ export type { MagicScrollOptions, MagicScrollToOptions, ScrollDataSource, ScrollResource, ScrollSource };