@mmstack/primitives 20.4.6 → 20.5.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/LICENSE +21 -21
- package/README.md +1038 -687
- package/fesm2022/mmstack-primitives.mjs +923 -292
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +456 -159
- package/package.json +1 -1
|
@@ -1,8 +1,218 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import {
|
|
2
|
+
import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, computed, signal, isSignal, ElementRef, PLATFORM_ID, Injectable, runInInjectionContext } from '@angular/core';
|
|
3
3
|
import { isPlatformServer } from '@angular/common';
|
|
4
4
|
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
5
5
|
|
|
6
|
+
const frameStack = [];
|
|
7
|
+
function currentFrame() {
|
|
8
|
+
return frameStack.at(-1) ?? null;
|
|
9
|
+
}
|
|
10
|
+
function clearFrame(frame, userCleanups) {
|
|
11
|
+
frame.parent = null;
|
|
12
|
+
for (const fn of userCleanups) {
|
|
13
|
+
try {
|
|
14
|
+
fn();
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
if (isDevMode())
|
|
18
|
+
console.error('Error destroying nested effect:', e);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
userCleanups.length = 0;
|
|
22
|
+
for (const child of frame.children) {
|
|
23
|
+
try {
|
|
24
|
+
child.destroy();
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (isDevMode())
|
|
28
|
+
console.error('Error destroying nested effect:', e);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
frame.children.clear();
|
|
32
|
+
}
|
|
33
|
+
function pushFrame(frame) {
|
|
34
|
+
return frameStack.push(frame);
|
|
35
|
+
}
|
|
36
|
+
function popFrame() {
|
|
37
|
+
return frameStack.pop();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates an effect that can be nested, similar to SolidJS's `createEffect`.
|
|
42
|
+
*
|
|
43
|
+
* This primitive enables true hierarchical reactivity. A `nestedEffect` created
|
|
44
|
+
* within another `nestedEffect` is automatically destroyed and recreated when
|
|
45
|
+
* the parent re-runs.
|
|
46
|
+
*
|
|
47
|
+
* It automatically handles injector propagation and lifetime management, allowing
|
|
48
|
+
* you to create fine-grained, conditional side-effects that only track
|
|
49
|
+
* dependencies when they are "live".
|
|
50
|
+
*
|
|
51
|
+
* @param effectFn The side-effect function, which receives a cleanup register function.
|
|
52
|
+
* @param options (Optional) Angular's `CreateEffectOptions`.
|
|
53
|
+
* @returns An `EffectRef` for the created effect.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* // Assume `coldGuard` changes rarely, but `hotSignal` changes often.
|
|
58
|
+
* const coldGuard = signal(false);
|
|
59
|
+
* const hotSignal = signal(0);
|
|
60
|
+
*
|
|
61
|
+
* nestedEffect(() => {
|
|
62
|
+
* // This outer effect only tracks `coldGuard`.
|
|
63
|
+
* if (coldGuard()) {
|
|
64
|
+
*
|
|
65
|
+
* // This inner effect is CREATED when coldGuard is true
|
|
66
|
+
* // and DESTROYED when it becomes false.
|
|
67
|
+
* nestedEffect(() => {
|
|
68
|
+
* // It only tracks `hotSignal` while it exists.
|
|
69
|
+
* console.log('Hot signal is:', hotSignal());
|
|
70
|
+
* });
|
|
71
|
+
* }
|
|
72
|
+
* // If `coldGuard` is false, this outer effect does not track `hotSignal`.
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const users = signal([
|
|
78
|
+
* { id: 1, name: 'Alice' },
|
|
79
|
+
* { id: 2, name: 'Bob' }
|
|
80
|
+
* ]);
|
|
81
|
+
*
|
|
82
|
+
* // The fine-grained mapped list
|
|
83
|
+
* const mappedUsers = mapArray(
|
|
84
|
+
* users,
|
|
85
|
+
* (userSignal, index) => {
|
|
86
|
+
* // 1. Create a fine-grained SIDE EFFECT for *this item*
|
|
87
|
+
* // This effect's lifetime is now tied to this specific item. created once on init of this index.
|
|
88
|
+
* const effectRef = nestedEffect(() => {
|
|
89
|
+
* // This only runs if *this* userSignal changes,
|
|
90
|
+
* // not if the whole list changes.
|
|
91
|
+
* console.log(`User ${index} updated:`, userSignal().name);
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* // 2. Return the data AND the cleanup logic
|
|
95
|
+
* return {
|
|
96
|
+
* // The mapped data
|
|
97
|
+
* label: computed(() => `User: ${userSignal().name}`),
|
|
98
|
+
*
|
|
99
|
+
* // The cleanup function
|
|
100
|
+
* destroyEffect: () => effectRef.destroy()
|
|
101
|
+
* };
|
|
102
|
+
* },
|
|
103
|
+
* {
|
|
104
|
+
* // 3. Tell mapArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
|
|
105
|
+
* onDestroy: (mappedItem) => {
|
|
106
|
+
* mappedItem.destroyEffect();
|
|
107
|
+
* }
|
|
108
|
+
* }
|
|
109
|
+
* );
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
function nestedEffect(effectFn, options) {
|
|
113
|
+
const bindToFrame = options?.bindToFrame ?? ((parent) => parent);
|
|
114
|
+
const parent = bindToFrame(currentFrame());
|
|
115
|
+
const injector = options?.injector ?? parent?.injector ?? inject(Injector);
|
|
116
|
+
let isDestroyed = false;
|
|
117
|
+
const srcRef = untracked(() => {
|
|
118
|
+
return effect((cleanup) => {
|
|
119
|
+
if (isDestroyed)
|
|
120
|
+
return;
|
|
121
|
+
const frame = {
|
|
122
|
+
injector,
|
|
123
|
+
parent,
|
|
124
|
+
children: new Set(),
|
|
125
|
+
};
|
|
126
|
+
const userCleanups = [];
|
|
127
|
+
pushFrame(frame);
|
|
128
|
+
try {
|
|
129
|
+
effectFn((fn) => userCleanups.push(fn));
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
popFrame();
|
|
133
|
+
}
|
|
134
|
+
return cleanup(() => clearFrame(frame, userCleanups));
|
|
135
|
+
}, {
|
|
136
|
+
...options,
|
|
137
|
+
injector,
|
|
138
|
+
manualCleanup: options?.manualCleanup ?? !!parent,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
const ref = {
|
|
142
|
+
destroy: () => {
|
|
143
|
+
if (isDestroyed)
|
|
144
|
+
return;
|
|
145
|
+
isDestroyed = true;
|
|
146
|
+
parent?.children.delete(ref);
|
|
147
|
+
srcRef.destroy();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
parent?.children.add(ref);
|
|
151
|
+
injector.get(DestroyRef).onDestroy(() => ref.destroy());
|
|
152
|
+
return ref;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Creates a new `Signal` that processes an array of items in time-sliced chunks. This is useful for handling large lists without blocking the main thread.
|
|
157
|
+
*
|
|
158
|
+
* The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `duration`.
|
|
159
|
+
*
|
|
160
|
+
* @template T The type of items in the array.
|
|
161
|
+
* @param source A `Signal` or a function that returns an array of items to be processed in chunks.
|
|
162
|
+
* @param options Configuration options for chunk size, delay duration, equality function, and injector.
|
|
163
|
+
* @returns A `Signal` that emits the current chunk of items being processed.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
|
|
167
|
+
* const chunkedList = chunked(largeList, { chunkSize: 100, duration: 100 });
|
|
168
|
+
*/
|
|
169
|
+
function chunked(source, options) {
|
|
170
|
+
const { chunkSize = 50, delay = 'frame', equal, injector } = options || {};
|
|
171
|
+
let delayFn;
|
|
172
|
+
if (delay === 'frame') {
|
|
173
|
+
delayFn = (callback) => {
|
|
174
|
+
const num = requestAnimationFrame(callback);
|
|
175
|
+
return () => cancelAnimationFrame(num);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
else if (delay === 'microtask') {
|
|
179
|
+
delayFn = (cb) => {
|
|
180
|
+
let isCancelled = false;
|
|
181
|
+
queueMicrotask(() => {
|
|
182
|
+
if (isCancelled)
|
|
183
|
+
return;
|
|
184
|
+
cb();
|
|
185
|
+
});
|
|
186
|
+
return () => {
|
|
187
|
+
isCancelled = true;
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
delayFn = (cb) => {
|
|
193
|
+
const num = setTimeout(cb, delay);
|
|
194
|
+
return () => clearTimeout(num);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const internal = linkedSignal(...(ngDevMode ? [{ debugName: "internal", source,
|
|
198
|
+
computation: (items) => items.slice(0, chunkSize),
|
|
199
|
+
equal }] : [{
|
|
200
|
+
source,
|
|
201
|
+
computation: (items) => items.slice(0, chunkSize),
|
|
202
|
+
equal,
|
|
203
|
+
}]));
|
|
204
|
+
nestedEffect((cleanup) => {
|
|
205
|
+
const fullList = source();
|
|
206
|
+
const current = internal();
|
|
207
|
+
if (current.length >= fullList.length)
|
|
208
|
+
return;
|
|
209
|
+
return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
|
|
210
|
+
}, {
|
|
211
|
+
injector: injector,
|
|
212
|
+
});
|
|
213
|
+
return internal.asReadonly();
|
|
214
|
+
}
|
|
215
|
+
|
|
6
216
|
/**
|
|
7
217
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
8
218
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -31,9 +241,9 @@ import { SIGNAL } from '@angular/core/primitives/signals';
|
|
|
31
241
|
*
|
|
32
242
|
* writableSignal.set(5); // sets value of originalValue.a to 5 & triggers all signals
|
|
33
243
|
*/
|
|
34
|
-
function toWritable(
|
|
35
|
-
const internal =
|
|
36
|
-
internal.asReadonly = () =>
|
|
244
|
+
function toWritable(source, set, update, opt) {
|
|
245
|
+
const internal = (opt?.pure !== false ? computed(source) : source);
|
|
246
|
+
internal.asReadonly = () => source;
|
|
37
247
|
internal.set = set;
|
|
38
248
|
internal.update = update ?? ((updater) => set(updater(untracked(internal))));
|
|
39
249
|
return internal;
|
|
@@ -115,19 +325,19 @@ function debounce(source, opt) {
|
|
|
115
325
|
catch {
|
|
116
326
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
117
327
|
}
|
|
118
|
-
const triggerFn = (
|
|
328
|
+
const triggerFn = (next) => {
|
|
119
329
|
if (timeout)
|
|
120
330
|
clearTimeout(timeout);
|
|
121
|
-
|
|
331
|
+
source.set(next);
|
|
122
332
|
timeout = setTimeout(() => {
|
|
123
333
|
trigger.update((c) => !c);
|
|
124
334
|
}, ms);
|
|
125
335
|
};
|
|
126
336
|
const set = (value) => {
|
|
127
|
-
triggerFn(
|
|
337
|
+
triggerFn(value);
|
|
128
338
|
};
|
|
129
339
|
const update = (fn) => {
|
|
130
|
-
triggerFn((
|
|
340
|
+
triggerFn(fn(untracked(source)));
|
|
131
341
|
};
|
|
132
342
|
const writable = toWritable(computed(() => {
|
|
133
343
|
trigger();
|
|
@@ -218,7 +428,7 @@ function derived(source, optOrKey, opt) {
|
|
|
218
428
|
: (next) => {
|
|
219
429
|
source.update((cur) => ({ ...cur, [optOrKey]: next }));
|
|
220
430
|
};
|
|
221
|
-
const rest = typeof optOrKey === 'object' ? optOrKey : opt;
|
|
431
|
+
const rest = typeof optOrKey === 'object' ? { ...optOrKey, ...opt } : opt;
|
|
222
432
|
const baseEqual = rest?.equal ?? Object.is;
|
|
223
433
|
let trigger = false;
|
|
224
434
|
const equal = isMutable(source)
|
|
@@ -228,7 +438,7 @@ function derived(source, optOrKey, opt) {
|
|
|
228
438
|
return baseEqual(a, b);
|
|
229
439
|
}
|
|
230
440
|
: baseEqual;
|
|
231
|
-
const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal));
|
|
441
|
+
const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal), undefined, { pure: false });
|
|
232
442
|
sig.from = from;
|
|
233
443
|
if (isMutable(source)) {
|
|
234
444
|
sig.mutate = (updater) => {
|
|
@@ -289,116 +499,8 @@ function isDerivation(sig) {
|
|
|
289
499
|
return 'from' in sig;
|
|
290
500
|
}
|
|
291
501
|
|
|
292
|
-
function
|
|
293
|
-
return typeof
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Creates a read-only signal that tracks the intersection status of a target DOM element
|
|
297
|
-
* with the viewport or a specified root element, using the `IntersectionObserver` API.
|
|
298
|
-
*
|
|
299
|
-
* It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one,
|
|
300
|
-
* allowing for dynamic targets.
|
|
301
|
-
*
|
|
302
|
-
* @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
|
|
303
|
-
* If the signal resolves to `null`, observation stops.
|
|
304
|
-
* @param options Optional `IntersectionObserverInit` options (e.g., `root`, `rootMargin`, `threshold`)
|
|
305
|
-
* and an optional `debugName`.
|
|
306
|
-
* @returns A `Signal<IntersectionObserverEntry | undefined>`. It emits `undefined` initially,
|
|
307
|
-
* on the server, or if the target is `null`. Otherwise, it emits the latest
|
|
308
|
-
* `IntersectionObserverEntry`. Consumers can derive a boolean `isVisible` from
|
|
309
|
-
* this entry's `isIntersecting` property.
|
|
310
|
-
*
|
|
311
|
-
* @example
|
|
312
|
-
* ```ts
|
|
313
|
-
* import { Component, effect, ElementRef, viewChild } from '@angular/core';
|
|
314
|
-
* import { elementVisibility } from '@mmstack/primitives';
|
|
315
|
-
* import { computed } from '@angular/core'; // For derived boolean
|
|
316
|
-
*
|
|
317
|
-
* @Component({
|
|
318
|
-
* selector: 'app-lazy-image',
|
|
319
|
-
* template: `
|
|
320
|
-
* <div #imageContainer style="height: 200px; border: 1px dashed grey;">
|
|
321
|
-
* @if (isVisible()) {
|
|
322
|
-
* <img src="your-image-url.jpg" alt="Lazy loaded image" />
|
|
323
|
-
* <p>Image is VISIBLE!</p>
|
|
324
|
-
* } @else {
|
|
325
|
-
* <p>Scroll down to see the image...</p>
|
|
326
|
-
* }
|
|
327
|
-
* </div>
|
|
328
|
-
* `
|
|
329
|
-
* })
|
|
330
|
-
* export class LazyImageComponent {
|
|
331
|
-
* readonly imageContainer = viewChild.required<ElementRef<HTMLDivElement>>('imageContainer');
|
|
332
|
-
*
|
|
333
|
-
* // Observe the element, get the full IntersectionObserverEntry
|
|
334
|
-
* readonly intersectionEntry = elementVisibility(this.imageContainer);
|
|
335
|
-
*
|
|
336
|
-
* // Derive a simple boolean for visibility
|
|
337
|
-
* readonly isVisible = computed(() => this.intersectionEntry()?.isIntersecting ?? false);
|
|
338
|
-
*
|
|
339
|
-
* constructor() {
|
|
340
|
-
* effect(() => {
|
|
341
|
-
* console.log('Intersection Entry:', this.intersectionEntry());
|
|
342
|
-
* console.log('Is Visible:', this.isVisible());
|
|
343
|
-
* });
|
|
344
|
-
* }
|
|
345
|
-
* }
|
|
346
|
-
* ```
|
|
347
|
-
*/
|
|
348
|
-
function elementVisibility(target = inject(ElementRef), opt) {
|
|
349
|
-
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
350
|
-
const base = computed(() => undefined, {
|
|
351
|
-
debugName: opt?.debugName,
|
|
352
|
-
});
|
|
353
|
-
base.visible = computed(() => false, ...(ngDevMode ? [{ debugName: "visible" }] : []));
|
|
354
|
-
return base;
|
|
355
|
-
}
|
|
356
|
-
const state = signal(undefined, {
|
|
357
|
-
debugName: opt?.debugName,
|
|
358
|
-
equal: (a, b) => {
|
|
359
|
-
if (!a && !b)
|
|
360
|
-
return true;
|
|
361
|
-
if (!a || !b)
|
|
362
|
-
return false;
|
|
363
|
-
return (a.target === b.target &&
|
|
364
|
-
a.isIntersecting === b.isIntersecting &&
|
|
365
|
-
a.intersectionRatio === b.intersectionRatio &&
|
|
366
|
-
a.boundingClientRect.top === b.boundingClientRect.top &&
|
|
367
|
-
a.boundingClientRect.left === b.boundingClientRect.left &&
|
|
368
|
-
a.boundingClientRect.width === b.boundingClientRect.width &&
|
|
369
|
-
a.boundingClientRect.height === b.boundingClientRect.height);
|
|
370
|
-
},
|
|
371
|
-
});
|
|
372
|
-
const targetSignal = isSignal(target) ? target : computed(() => target);
|
|
373
|
-
effect((cleanup) => {
|
|
374
|
-
const el = targetSignal();
|
|
375
|
-
if (!el)
|
|
376
|
-
return state.set(undefined);
|
|
377
|
-
let observer = null;
|
|
378
|
-
observer = new IntersectionObserver(([entry]) => state.set(entry), opt);
|
|
379
|
-
observer.observe(el instanceof ElementRef ? el.nativeElement : el);
|
|
380
|
-
cleanup(() => {
|
|
381
|
-
observer?.disconnect();
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
const base = state.asReadonly();
|
|
385
|
-
base.visible = computed(() => {
|
|
386
|
-
const s = state();
|
|
387
|
-
if (!s)
|
|
388
|
-
return false;
|
|
389
|
-
return s.isIntersecting;
|
|
390
|
-
}, ...(ngDevMode ? [{ debugName: "visible" }] : []));
|
|
391
|
-
return base;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* @internal
|
|
396
|
-
* Checks if a signal is a WritableSignal.
|
|
397
|
-
* @param sig The signal to check.
|
|
398
|
-
*/
|
|
399
|
-
function isWritable(sig) {
|
|
400
|
-
// We just need to check for the presence of a 'set' method.
|
|
401
|
-
return 'set' in sig;
|
|
502
|
+
function isWritableSignal(value) {
|
|
503
|
+
return 'set' in value && typeof value.set === 'function';
|
|
402
504
|
}
|
|
403
505
|
/**
|
|
404
506
|
* @internal
|
|
@@ -407,31 +509,45 @@ function isWritable(sig) {
|
|
|
407
509
|
* @returns
|
|
408
510
|
*/
|
|
409
511
|
function createSetter(source) {
|
|
410
|
-
if (!
|
|
512
|
+
if (!isWritableSignal(source))
|
|
411
513
|
return () => {
|
|
412
514
|
// noop;
|
|
413
515
|
};
|
|
414
516
|
if (isMutable(source))
|
|
415
517
|
return (value, index) => {
|
|
416
|
-
source.
|
|
518
|
+
source.mutate((arr) => {
|
|
417
519
|
arr[index] = value;
|
|
520
|
+
return arr;
|
|
418
521
|
});
|
|
419
522
|
};
|
|
420
523
|
return (value, index) => {
|
|
421
524
|
source.update((arr) => arr.map((v, i) => (i === index ? value : v)));
|
|
422
525
|
};
|
|
423
526
|
}
|
|
424
|
-
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Helper to create the derived signal for a specific index.
|
|
530
|
+
* Extracts the cast logic to keep the main loop clean.
|
|
531
|
+
*/
|
|
532
|
+
function createItemSignal(source, index, setter, opt) {
|
|
533
|
+
return derived(
|
|
534
|
+
// We cast to any/Mutable to satisfy the overload signature,
|
|
535
|
+
// but 'derived' internally checks isMutable() for safety.
|
|
536
|
+
source, {
|
|
537
|
+
from: (src) => src[index],
|
|
538
|
+
onChange: (value) => setter(value, index),
|
|
539
|
+
}, opt);
|
|
540
|
+
}
|
|
541
|
+
function indexArray(source, map, opt = {}) {
|
|
425
542
|
const data = isSignal(source) ? source : computed(source);
|
|
426
543
|
const len = computed(() => data().length, ...(ngDevMode ? [{ debugName: "len" }] : []));
|
|
427
544
|
const setter = createSetter(data);
|
|
428
|
-
const
|
|
429
|
-
const writableData = isWritable(data)
|
|
545
|
+
const writableData = isWritableSignal(data)
|
|
430
546
|
? data
|
|
431
547
|
: toWritable(data, () => {
|
|
432
548
|
// noop
|
|
433
549
|
});
|
|
434
|
-
if (
|
|
550
|
+
if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
|
|
435
551
|
opt.equal = (a, b) => {
|
|
436
552
|
if (a !== b)
|
|
437
553
|
return false; // actually check primitives and references
|
|
@@ -442,175 +558,201 @@ function mapArray(source, map, options) {
|
|
|
442
558
|
source: () => len(),
|
|
443
559
|
computation: (len, prev) => {
|
|
444
560
|
if (!prev)
|
|
445
|
-
return Array.from({ length: len }, (_, i) =>
|
|
446
|
-
const derivation = derived(writableData, // typcase to largest type
|
|
447
|
-
{
|
|
448
|
-
from: (src) => src[i],
|
|
449
|
-
onChange: (value) => setter(value, i),
|
|
450
|
-
}, opt);
|
|
451
|
-
return map(derivation, i);
|
|
452
|
-
});
|
|
561
|
+
return Array.from({ length: len }, (_, i) => map(createItemSignal(writableData, i, setter, opt), i));
|
|
453
562
|
if (len === prev.value.length)
|
|
454
563
|
return prev.value;
|
|
455
564
|
if (len < prev.value.length) {
|
|
456
|
-
const slice = prev.value.slice(0, len);
|
|
457
565
|
if (opt.onDestroy) {
|
|
458
566
|
for (let i = len; i < prev.value.length; i++) {
|
|
459
|
-
opt.onDestroy
|
|
567
|
+
opt.onDestroy(prev.value[i]);
|
|
460
568
|
}
|
|
461
569
|
}
|
|
462
|
-
return slice;
|
|
463
|
-
}
|
|
464
|
-
else {
|
|
465
|
-
const next = [...prev.value];
|
|
466
|
-
for (let i = prev.value.length; i < len; i++) {
|
|
467
|
-
const derivation = derived(writableData, // typcase to largest type
|
|
468
|
-
{
|
|
469
|
-
from: (src) => src[i],
|
|
470
|
-
onChange: (value) => setter(value, i),
|
|
471
|
-
}, opt);
|
|
472
|
-
next[i] = map(derivation, i);
|
|
473
|
-
}
|
|
474
|
-
return next;
|
|
570
|
+
return prev.value.slice(0, len);
|
|
475
571
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
{ id: 1, name: 'Alice' },
|
|
546
|
-
{ id: 2, name: 'Bob' }
|
|
547
|
-
]);
|
|
548
|
-
|
|
549
|
-
// The fine-grained mapped list
|
|
550
|
-
const mappedUsers = mapArray(
|
|
551
|
-
users,
|
|
552
|
-
(userSignal, index) => {
|
|
553
|
-
// 1. Create a fine-grained SIDE EFFECT for *this item*
|
|
554
|
-
// This effect's lifetime is now tied to this specific item. created once on init of this index.
|
|
555
|
-
const effectRef = nestedEffect(() => {
|
|
556
|
-
// This only runs if *this* userSignal changes,
|
|
557
|
-
// not if the whole list changes.
|
|
558
|
-
console.log(`User ${index} updated:`, userSignal().name);
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
// 2. Return the data AND the cleanup logic
|
|
562
|
-
return {
|
|
563
|
-
// The mapped data
|
|
564
|
-
label: computed(() => `User: ${userSignal().name}`),
|
|
565
|
-
|
|
566
|
-
// The cleanup function
|
|
567
|
-
destroyEffect: () => effectRef.destroy()
|
|
568
|
-
};
|
|
569
|
-
},
|
|
570
|
-
{
|
|
571
|
-
// 3. Tell mapArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
|
|
572
|
-
onDestroy: (mappedItem) => {
|
|
573
|
-
mappedItem.destroyEffect();
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
);
|
|
577
|
-
* ```
|
|
578
|
-
*/
|
|
579
|
-
function nestedEffect(effectFn, options) {
|
|
580
|
-
const parent = current();
|
|
581
|
-
const injector = options?.injector ?? parent?.injector ?? inject(Injector);
|
|
582
|
-
const srcRef = untracked(() => {
|
|
583
|
-
return effect((cleanup) => {
|
|
584
|
-
const frame = {
|
|
585
|
-
injector,
|
|
586
|
-
children: new Set(),
|
|
587
|
-
};
|
|
588
|
-
const userCleanups = [];
|
|
589
|
-
frameStack.push(frame);
|
|
590
|
-
try {
|
|
591
|
-
effectFn((fn) => {
|
|
592
|
-
userCleanups.push(fn);
|
|
593
|
-
});
|
|
572
|
+
const next = prev.value.slice();
|
|
573
|
+
for (let i = prev.value.length; i < len; i++)
|
|
574
|
+
next[i] = map(createItemSignal(writableData, i, setter, opt), i);
|
|
575
|
+
return next;
|
|
576
|
+
},
|
|
577
|
+
equal: (a, b) => a.length === b.length,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* @deprecated use indexArray instead
|
|
582
|
+
*/
|
|
583
|
+
const mapArray = indexArray;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Reactively maps items from a source array to a new array by value (identity).
|
|
587
|
+
*
|
|
588
|
+
* similar to `Array.prototype.map`, but:
|
|
589
|
+
* 1. The `mapFn` receives the `index` as a Signal.
|
|
590
|
+
* 2. If an item in the `source` array moves to a new position, the *result* of the map function is reused and moved.
|
|
591
|
+
* The `index` signal is updated to the new index.
|
|
592
|
+
* 3. The `mapFn` is only run for *new* items.
|
|
593
|
+
*
|
|
594
|
+
* This is useful for building efficient lists where DOM nodes or heavy instances should be reused
|
|
595
|
+
* when the list is reordered.
|
|
596
|
+
*
|
|
597
|
+
* @param source A `Signal<T[]>` or a function returning `T[]`.
|
|
598
|
+
* @param mapFn The mapping function. Receives the item and its index as a Signal.
|
|
599
|
+
* @param options Optional configuration:
|
|
600
|
+
* - `onDestroy`: A callback invoked when a mapped item is removed from the array.
|
|
601
|
+
* @returns A `Signal<U[]>` containing the mapped array.
|
|
602
|
+
*/
|
|
603
|
+
function keyArray(source, mapFn, options = {}) {
|
|
604
|
+
const sourceSignal = isSignal(source) ? source : computed(source);
|
|
605
|
+
const items = [];
|
|
606
|
+
let mapped = [];
|
|
607
|
+
const indexes = [];
|
|
608
|
+
const getKey = options.key || ((v) => v);
|
|
609
|
+
const newIndices = new Map();
|
|
610
|
+
const temp = [];
|
|
611
|
+
const tempIndexes = [];
|
|
612
|
+
const newIndicesNext = [];
|
|
613
|
+
const newIndexesCache = new Array();
|
|
614
|
+
return computed(() => {
|
|
615
|
+
const newItems = sourceSignal() || [];
|
|
616
|
+
return untracked(() => {
|
|
617
|
+
let i;
|
|
618
|
+
let j;
|
|
619
|
+
const newLen = newItems.length;
|
|
620
|
+
const len = items.length;
|
|
621
|
+
const newMapped = new Array(newLen);
|
|
622
|
+
const newIndexes = newIndexesCache;
|
|
623
|
+
newIndexes.length = 0;
|
|
624
|
+
newIndexes.length = newLen;
|
|
625
|
+
let start;
|
|
626
|
+
let end;
|
|
627
|
+
let newEnd;
|
|
628
|
+
let item;
|
|
629
|
+
let key;
|
|
630
|
+
if (newLen === 0) {
|
|
631
|
+
if (len !== 0) {
|
|
632
|
+
if (options.onDestroy) {
|
|
633
|
+
for (let k = 0; k < len; k++)
|
|
634
|
+
options.onDestroy(mapped[k]);
|
|
635
|
+
}
|
|
636
|
+
items.length = 0;
|
|
637
|
+
mapped = [];
|
|
638
|
+
indexes.length = 0;
|
|
639
|
+
}
|
|
640
|
+
return mapped;
|
|
594
641
|
}
|
|
595
|
-
|
|
596
|
-
|
|
642
|
+
if (len === 0) {
|
|
643
|
+
for (j = 0; j < newLen; j++) {
|
|
644
|
+
item = newItems[j];
|
|
645
|
+
items[j] = item;
|
|
646
|
+
const indexSignal = signal(j, ...(ngDevMode ? [{ debugName: "indexSignal" }] : []));
|
|
647
|
+
newIndexes[j] = indexSignal;
|
|
648
|
+
newMapped[j] = mapFn(item, indexSignal);
|
|
649
|
+
}
|
|
597
650
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
651
|
+
else {
|
|
652
|
+
newIndices.clear();
|
|
653
|
+
temp.length = 0;
|
|
654
|
+
tempIndexes.length = 0;
|
|
655
|
+
newIndicesNext.length = 0;
|
|
656
|
+
for (start = 0, end = Math.min(len, newLen); start < end && getKey(items[start]) === getKey(newItems[start]); start++) {
|
|
657
|
+
newMapped[start] = mapped[start];
|
|
658
|
+
newIndexes[start] = indexes[start];
|
|
659
|
+
}
|
|
660
|
+
for (end = len - 1, newEnd = newLen - 1; end >= start &&
|
|
661
|
+
newEnd >= start &&
|
|
662
|
+
getKey(items[end]) === getKey(newItems[newEnd]); end--, newEnd--) {
|
|
663
|
+
temp[newEnd] = mapped[end];
|
|
664
|
+
tempIndexes[newEnd] = indexes[end];
|
|
665
|
+
}
|
|
666
|
+
for (j = newEnd; j >= start; j--) {
|
|
667
|
+
item = newItems[j];
|
|
668
|
+
key = getKey(item);
|
|
669
|
+
i = newIndices.get(key);
|
|
670
|
+
newIndicesNext[j] = i === undefined ? -1 : i;
|
|
671
|
+
newIndices.set(key, j);
|
|
672
|
+
}
|
|
673
|
+
for (i = start; i <= end; i++) {
|
|
674
|
+
item = items[i];
|
|
675
|
+
key = getKey(item);
|
|
676
|
+
j = newIndices.get(key);
|
|
677
|
+
if (j !== undefined && j !== -1) {
|
|
678
|
+
temp[j] = mapped[i];
|
|
679
|
+
tempIndexes[j] = indexes[i];
|
|
680
|
+
j = newIndicesNext[j];
|
|
681
|
+
newIndices.set(key, j);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
if (options.onDestroy)
|
|
685
|
+
options.onDestroy(mapped[i]);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// 2) Set all new values
|
|
689
|
+
for (j = start; j < newLen; j++) {
|
|
690
|
+
if (temp[j] !== undefined) {
|
|
691
|
+
newMapped[j] = temp[j];
|
|
692
|
+
newIndexes[j] = tempIndexes[j];
|
|
693
|
+
newIndexes[j].set(j);
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
const indexSignal = signal(j, ...(ngDevMode ? [{ debugName: "indexSignal" }] : []));
|
|
697
|
+
newIndexes[j] = indexSignal;
|
|
698
|
+
newMapped[j] = mapFn(newItems[j], indexSignal);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
items.length = newLen;
|
|
702
|
+
for (let k = 0; k < newLen; k++)
|
|
703
|
+
items[k] = newItems[k];
|
|
704
|
+
}
|
|
705
|
+
mapped = newMapped;
|
|
706
|
+
indexes.length = newLen;
|
|
707
|
+
for (let k = 0; k < newLen; k++)
|
|
708
|
+
indexes[k] = newIndexes[k];
|
|
709
|
+
return mapped;
|
|
603
710
|
});
|
|
604
711
|
});
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function pooledKeys(src) {
|
|
715
|
+
const aBuf = new Set();
|
|
716
|
+
const bBuf = new Set();
|
|
717
|
+
let active = aBuf;
|
|
718
|
+
let spare = bBuf;
|
|
719
|
+
return computed(() => {
|
|
720
|
+
const val = src();
|
|
721
|
+
spare.clear();
|
|
722
|
+
for (const k in val)
|
|
723
|
+
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
724
|
+
spare.add(k);
|
|
725
|
+
if (active.size === spare.size && active.isSubsetOf(spare))
|
|
726
|
+
return active;
|
|
727
|
+
const temp = active;
|
|
728
|
+
active = spare;
|
|
729
|
+
spare = temp;
|
|
730
|
+
return active;
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
function mapObject(source, mapFn, options = {}) {
|
|
734
|
+
const src = isSignal(source) ? source : computed(source);
|
|
735
|
+
const writable = (isWritableSignal(src)
|
|
736
|
+
? src
|
|
737
|
+
: toWritable(src, () => {
|
|
738
|
+
// noop
|
|
739
|
+
})); // maximal overload internally
|
|
740
|
+
return linkedSignal({
|
|
741
|
+
source: pooledKeys(src),
|
|
742
|
+
computation: (next, prev) => {
|
|
743
|
+
const nextObj = {};
|
|
744
|
+
for (const k of next)
|
|
745
|
+
nextObj[k] =
|
|
746
|
+
prev && prev.source.has(k)
|
|
747
|
+
? prev.value[k]
|
|
748
|
+
: mapFn(k, derived(writable, k));
|
|
749
|
+
if (options.onDestroy && prev && prev.source.size)
|
|
750
|
+
for (const k of prev.source)
|
|
751
|
+
if (!next.has(k))
|
|
752
|
+
options.onDestroy(prev.value[k]);
|
|
753
|
+
return nextObj;
|
|
610
754
|
},
|
|
611
|
-
};
|
|
612
|
-
parent?.children.add(ref);
|
|
613
|
-
return ref;
|
|
755
|
+
}).asReadonly();
|
|
614
756
|
}
|
|
615
757
|
|
|
616
758
|
/** Project with optional equality. Pure & sync. */
|
|
@@ -698,6 +840,206 @@ function piped(initial, opt) {
|
|
|
698
840
|
return pipeable(signal(initial, opt));
|
|
699
841
|
}
|
|
700
842
|
|
|
843
|
+
function observerSupported$1() {
|
|
844
|
+
return typeof ResizeObserver !== 'undefined';
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Creates a read-only signal that tracks the size of a target DOM element.
|
|
848
|
+
*
|
|
849
|
+
* By default, it observes the `border-box` size to align with `getBoundingClientRect()`,
|
|
850
|
+
* which is used to provide a synchronous initial value if possible.
|
|
851
|
+
*
|
|
852
|
+
* @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
|
|
853
|
+
* @param options Optional configuration including `box` (defaults to 'border-box') and `debugName`.
|
|
854
|
+
* @returns A `Signal<ElementSize | undefined>`.
|
|
855
|
+
*
|
|
856
|
+
* @example
|
|
857
|
+
* ```ts
|
|
858
|
+
* const size = elementSize(elementRef);
|
|
859
|
+
* effect(() => {
|
|
860
|
+
* console.log('Size:', size()?.width, size()?.height);
|
|
861
|
+
* });
|
|
862
|
+
* ```
|
|
863
|
+
*/
|
|
864
|
+
function elementSize(target = inject(ElementRef), opt) {
|
|
865
|
+
const getElement = () => {
|
|
866
|
+
if (isSignal(target)) {
|
|
867
|
+
try {
|
|
868
|
+
const val = target();
|
|
869
|
+
return val instanceof ElementRef ? val.nativeElement : val;
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return target instanceof ElementRef ? target.nativeElement : target;
|
|
876
|
+
};
|
|
877
|
+
const resolveInitialValue = () => {
|
|
878
|
+
if (!observerSupported$1())
|
|
879
|
+
return undefined;
|
|
880
|
+
const el = getElement();
|
|
881
|
+
if (el && el.getBoundingClientRect) {
|
|
882
|
+
const rect = el.getBoundingClientRect();
|
|
883
|
+
return { width: rect.width, height: rect.height };
|
|
884
|
+
}
|
|
885
|
+
return undefined;
|
|
886
|
+
};
|
|
887
|
+
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
888
|
+
return computed(() => untracked(resolveInitialValue), {
|
|
889
|
+
debugName: opt?.debugName,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
const state = signal(untracked(resolveInitialValue), {
|
|
893
|
+
debugName: opt?.debugName,
|
|
894
|
+
equal: (a, b) => a?.width === b?.width && a?.height === b?.height,
|
|
895
|
+
});
|
|
896
|
+
const targetSignal = isSignal(target) ? target : computed(() => target);
|
|
897
|
+
effect((cleanup) => {
|
|
898
|
+
const el = targetSignal();
|
|
899
|
+
if (el) {
|
|
900
|
+
const nativeEl = el instanceof ElementRef ? el.nativeElement : el;
|
|
901
|
+
const rect = nativeEl.getBoundingClientRect();
|
|
902
|
+
untracked(() => state.set({ width: rect.width, height: rect.height }));
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
untracked(() => state.set(undefined));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (!observerSupported$1())
|
|
909
|
+
return;
|
|
910
|
+
let observer = null;
|
|
911
|
+
observer = new ResizeObserver(([entry]) => {
|
|
912
|
+
let width = 0;
|
|
913
|
+
let height = 0;
|
|
914
|
+
const boxOption = opt?.box ?? 'border-box';
|
|
915
|
+
if (boxOption === 'border-box' && entry.borderBoxSize?.length > 0) {
|
|
916
|
+
const size = entry.borderBoxSize[0];
|
|
917
|
+
width = size.inlineSize;
|
|
918
|
+
height = size.blockSize;
|
|
919
|
+
}
|
|
920
|
+
else if (boxOption === 'content-box' &&
|
|
921
|
+
entry.contentBoxSize?.length > 0) {
|
|
922
|
+
width = entry.contentBoxSize[0].inlineSize;
|
|
923
|
+
height = entry.contentBoxSize[0].blockSize;
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
width = entry.contentRect.width;
|
|
927
|
+
height = entry.contentRect.height;
|
|
928
|
+
}
|
|
929
|
+
state.set({ width, height });
|
|
930
|
+
});
|
|
931
|
+
observer.observe(el instanceof ElementRef ? el.nativeElement : el, {
|
|
932
|
+
box: opt?.box ?? 'border-box',
|
|
933
|
+
});
|
|
934
|
+
cleanup(() => {
|
|
935
|
+
observer?.disconnect();
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
return state.asReadonly();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function observerSupported() {
|
|
942
|
+
return typeof IntersectionObserver !== 'undefined';
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Creates a read-only signal that tracks the intersection status of a target DOM element
|
|
946
|
+
* with the viewport or a specified root element, using the `IntersectionObserver` API.
|
|
947
|
+
*
|
|
948
|
+
* It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one,
|
|
949
|
+
* allowing for dynamic targets.
|
|
950
|
+
*
|
|
951
|
+
* @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
|
|
952
|
+
* If the signal resolves to `null`, observation stops.
|
|
953
|
+
* @param options Optional `IntersectionObserverInit` options (e.g., `root`, `rootMargin`, `threshold`)
|
|
954
|
+
* and an optional `debugName`.
|
|
955
|
+
* @returns A `Signal<IntersectionObserverEntry | undefined>`. It emits `undefined` initially,
|
|
956
|
+
* on the server, or if the target is `null`. Otherwise, it emits the latest
|
|
957
|
+
* `IntersectionObserverEntry`. Consumers can derive a boolean `isVisible` from
|
|
958
|
+
* this entry's `isIntersecting` property.
|
|
959
|
+
*
|
|
960
|
+
* @example
|
|
961
|
+
* ```ts
|
|
962
|
+
* import { Component, effect, ElementRef, viewChild } from '@angular/core';
|
|
963
|
+
* import { elementVisibility } from '@mmstack/primitives';
|
|
964
|
+
* import { computed } from '@angular/core'; // For derived boolean
|
|
965
|
+
*
|
|
966
|
+
* @Component({
|
|
967
|
+
* selector: 'app-lazy-image',
|
|
968
|
+
* template: `
|
|
969
|
+
* <div #imageContainer style="height: 200px; border: 1px dashed grey;">
|
|
970
|
+
* @if (isVisible()) {
|
|
971
|
+
* <img src="your-image-url.jpg" alt="Lazy loaded image" />
|
|
972
|
+
* <p>Image is VISIBLE!</p>
|
|
973
|
+
* } @else {
|
|
974
|
+
* <p>Scroll down to see the image...</p>
|
|
975
|
+
* }
|
|
976
|
+
* </div>
|
|
977
|
+
* `
|
|
978
|
+
* })
|
|
979
|
+
* export class LazyImageComponent {
|
|
980
|
+
* readonly imageContainer = viewChild.required<ElementRef<HTMLDivElement>>('imageContainer');
|
|
981
|
+
*
|
|
982
|
+
* // Observe the element, get the full IntersectionObserverEntry
|
|
983
|
+
* readonly intersectionEntry = elementVisibility(this.imageContainer);
|
|
984
|
+
*
|
|
985
|
+
* // Derive a simple boolean for visibility
|
|
986
|
+
* readonly isVisible = computed(() => this.intersectionEntry()?.isIntersecting ?? false);
|
|
987
|
+
*
|
|
988
|
+
* constructor() {
|
|
989
|
+
* effect(() => {
|
|
990
|
+
* console.log('Intersection Entry:', this.intersectionEntry());
|
|
991
|
+
* console.log('Is Visible:', this.isVisible());
|
|
992
|
+
* });
|
|
993
|
+
* }
|
|
994
|
+
* }
|
|
995
|
+
* ```
|
|
996
|
+
*/
|
|
997
|
+
function elementVisibility(target = inject(ElementRef), opt) {
|
|
998
|
+
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
999
|
+
const base = computed(() => undefined, {
|
|
1000
|
+
debugName: opt?.debugName,
|
|
1001
|
+
});
|
|
1002
|
+
base.visible = computed(() => false, ...(ngDevMode ? [{ debugName: "visible" }] : []));
|
|
1003
|
+
return base;
|
|
1004
|
+
}
|
|
1005
|
+
const state = signal(undefined, {
|
|
1006
|
+
debugName: opt?.debugName,
|
|
1007
|
+
equal: (a, b) => {
|
|
1008
|
+
if (!a && !b)
|
|
1009
|
+
return true;
|
|
1010
|
+
if (!a || !b)
|
|
1011
|
+
return false;
|
|
1012
|
+
return (a.target === b.target &&
|
|
1013
|
+
a.isIntersecting === b.isIntersecting &&
|
|
1014
|
+
a.intersectionRatio === b.intersectionRatio &&
|
|
1015
|
+
a.boundingClientRect.top === b.boundingClientRect.top &&
|
|
1016
|
+
a.boundingClientRect.left === b.boundingClientRect.left &&
|
|
1017
|
+
a.boundingClientRect.width === b.boundingClientRect.width &&
|
|
1018
|
+
a.boundingClientRect.height === b.boundingClientRect.height);
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
const targetSignal = isSignal(target) ? target : computed(() => target);
|
|
1022
|
+
effect((cleanup) => {
|
|
1023
|
+
const el = targetSignal();
|
|
1024
|
+
if (!el)
|
|
1025
|
+
return state.set(undefined);
|
|
1026
|
+
let observer = null;
|
|
1027
|
+
observer = new IntersectionObserver(([entry]) => state.set(entry), opt);
|
|
1028
|
+
observer.observe(el instanceof ElementRef ? el.nativeElement : el);
|
|
1029
|
+
cleanup(() => {
|
|
1030
|
+
observer?.disconnect();
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
const base = state.asReadonly();
|
|
1034
|
+
base.visible = computed(() => {
|
|
1035
|
+
const s = state();
|
|
1036
|
+
if (!s)
|
|
1037
|
+
return false;
|
|
1038
|
+
return s.isIntersecting;
|
|
1039
|
+
}, ...(ngDevMode ? [{ debugName: "visible" }] : []));
|
|
1040
|
+
return base;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
701
1043
|
/**
|
|
702
1044
|
* Creates a read-only signal that reactively tracks whether a CSS media query
|
|
703
1045
|
* string currently matches.
|
|
@@ -869,8 +1211,7 @@ function throttle(source, opt) {
|
|
|
869
1211
|
catch {
|
|
870
1212
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
871
1213
|
}
|
|
872
|
-
const
|
|
873
|
-
updateSourceAction();
|
|
1214
|
+
const tick = () => {
|
|
874
1215
|
if (timeout)
|
|
875
1216
|
return;
|
|
876
1217
|
timeout = setTimeout(() => {
|
|
@@ -879,10 +1220,12 @@ function throttle(source, opt) {
|
|
|
879
1220
|
}, ms);
|
|
880
1221
|
};
|
|
881
1222
|
const set = (value) => {
|
|
882
|
-
|
|
1223
|
+
source.set(value);
|
|
1224
|
+
tick();
|
|
883
1225
|
};
|
|
884
1226
|
const update = (fn) => {
|
|
885
|
-
|
|
1227
|
+
source.update(fn);
|
|
1228
|
+
tick();
|
|
886
1229
|
};
|
|
887
1230
|
const writable = toWritable(computed(() => {
|
|
888
1231
|
trigger();
|
|
@@ -1250,18 +1593,303 @@ function sensor(type, options) {
|
|
|
1250
1593
|
return networkStatus(options?.debugName);
|
|
1251
1594
|
case 'pageVisibility':
|
|
1252
1595
|
return pageVisibility(options?.debugName);
|
|
1596
|
+
case 'darkMode':
|
|
1253
1597
|
case 'dark-mode':
|
|
1254
1598
|
return prefersDarkMode(options?.debugName);
|
|
1599
|
+
case 'reducedMotion':
|
|
1255
1600
|
case 'reduced-motion':
|
|
1256
1601
|
return prefersReducedMotion(options?.debugName);
|
|
1602
|
+
case 'mediaQuery': {
|
|
1603
|
+
const opt = options;
|
|
1604
|
+
return mediaQuery(opt.query, opt.debugName);
|
|
1605
|
+
}
|
|
1257
1606
|
case 'windowSize':
|
|
1258
1607
|
return windowSize(options);
|
|
1259
1608
|
case 'scrollPosition':
|
|
1260
1609
|
return scrollPosition(options);
|
|
1610
|
+
case 'elementVisibility': {
|
|
1611
|
+
const opt = options;
|
|
1612
|
+
return elementVisibility(opt.target, opt);
|
|
1613
|
+
}
|
|
1614
|
+
case 'elementSize': {
|
|
1615
|
+
const opt = options;
|
|
1616
|
+
return elementSize(opt.target, opt);
|
|
1617
|
+
}
|
|
1261
1618
|
default:
|
|
1262
1619
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
1263
1620
|
}
|
|
1264
1621
|
}
|
|
1622
|
+
function sensors(track, opt) {
|
|
1623
|
+
return track.reduce((result, key) => {
|
|
1624
|
+
result[key] = sensor(key, opt?.[key]);
|
|
1625
|
+
return result;
|
|
1626
|
+
}, {});
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const IS_STORE = Symbol('MMSTACK::IS_STORE');
|
|
1630
|
+
const PROXY_CACHE = new WeakMap();
|
|
1631
|
+
const SIGNAL_FN_PROP = new Set([
|
|
1632
|
+
'set',
|
|
1633
|
+
'update',
|
|
1634
|
+
'mutate',
|
|
1635
|
+
'inline',
|
|
1636
|
+
'asReadonly',
|
|
1637
|
+
]);
|
|
1638
|
+
const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
|
|
1639
|
+
const storeCache = PROXY_CACHE.get(target);
|
|
1640
|
+
if (storeCache)
|
|
1641
|
+
storeCache.delete(prop);
|
|
1642
|
+
});
|
|
1643
|
+
/**
|
|
1644
|
+
* @internal
|
|
1645
|
+
* Validates whether a value is a Signal Store.
|
|
1646
|
+
*/
|
|
1647
|
+
function isStore(value) {
|
|
1648
|
+
return (typeof value === 'function' &&
|
|
1649
|
+
value !== null &&
|
|
1650
|
+
value[IS_STORE] === true);
|
|
1651
|
+
}
|
|
1652
|
+
function isIndexProp(prop) {
|
|
1653
|
+
return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
|
|
1654
|
+
}
|
|
1655
|
+
function isRecord(value) {
|
|
1656
|
+
if (value === null || typeof value !== 'object')
|
|
1657
|
+
return false;
|
|
1658
|
+
const proto = Object.getPrototypeOf(value);
|
|
1659
|
+
return proto === Object.prototype || proto === null;
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* @internal
|
|
1663
|
+
* Makes an array store
|
|
1664
|
+
*/
|
|
1665
|
+
function toArrayStore(source, injector) {
|
|
1666
|
+
if (isStore(source))
|
|
1667
|
+
return source;
|
|
1668
|
+
const isMutableSource = isMutable(source);
|
|
1669
|
+
const lengthSignal = computed(() => {
|
|
1670
|
+
const v = source();
|
|
1671
|
+
if (!Array.isArray(v))
|
|
1672
|
+
return 0;
|
|
1673
|
+
return v.length;
|
|
1674
|
+
}, ...(ngDevMode ? [{ debugName: "lengthSignal" }] : []));
|
|
1675
|
+
return new Proxy(source, {
|
|
1676
|
+
has(_, prop) {
|
|
1677
|
+
if (prop === 'length')
|
|
1678
|
+
return true;
|
|
1679
|
+
if (isIndexProp(prop)) {
|
|
1680
|
+
const idx = +prop;
|
|
1681
|
+
return idx >= 0 && idx < untracked(lengthSignal);
|
|
1682
|
+
}
|
|
1683
|
+
return Reflect.has(untracked(source), prop);
|
|
1684
|
+
},
|
|
1685
|
+
ownKeys() {
|
|
1686
|
+
const v = untracked(source);
|
|
1687
|
+
if (!Array.isArray(v))
|
|
1688
|
+
return [];
|
|
1689
|
+
const len = v.length;
|
|
1690
|
+
const arr = new Array(len + 1);
|
|
1691
|
+
for (let i = 0; i < len; i++) {
|
|
1692
|
+
arr[i] = String(i);
|
|
1693
|
+
}
|
|
1694
|
+
arr[len] = 'length';
|
|
1695
|
+
return arr;
|
|
1696
|
+
},
|
|
1697
|
+
getPrototypeOf() {
|
|
1698
|
+
return Array.prototype;
|
|
1699
|
+
},
|
|
1700
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
1701
|
+
const v = untracked(source);
|
|
1702
|
+
if (!Array.isArray(v))
|
|
1703
|
+
return;
|
|
1704
|
+
if (prop === 'length' ||
|
|
1705
|
+
(typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
|
|
1706
|
+
return {
|
|
1707
|
+
enumerable: true,
|
|
1708
|
+
configurable: true, // Required for proxies to dynamic targets
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
return;
|
|
1712
|
+
},
|
|
1713
|
+
get(target, prop, receiver) {
|
|
1714
|
+
if (prop === IS_STORE)
|
|
1715
|
+
return true;
|
|
1716
|
+
if (prop === 'length')
|
|
1717
|
+
return lengthSignal;
|
|
1718
|
+
if (prop === Symbol.iterator) {
|
|
1719
|
+
return function* () {
|
|
1720
|
+
for (let i = 0; i < untracked(lengthSignal); i++) {
|
|
1721
|
+
yield receiver[i];
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
|
|
1726
|
+
return target[prop];
|
|
1727
|
+
if (isIndexProp(prop)) {
|
|
1728
|
+
const idx = +prop;
|
|
1729
|
+
let storeCache = PROXY_CACHE.get(target);
|
|
1730
|
+
if (!storeCache) {
|
|
1731
|
+
storeCache = new Map();
|
|
1732
|
+
PROXY_CACHE.set(target, storeCache);
|
|
1733
|
+
}
|
|
1734
|
+
const cachedRef = storeCache.get(idx);
|
|
1735
|
+
if (cachedRef) {
|
|
1736
|
+
const cached = cachedRef.deref();
|
|
1737
|
+
if (cached)
|
|
1738
|
+
return cached;
|
|
1739
|
+
storeCache.delete(idx);
|
|
1740
|
+
PROXY_CLEANUP.unregister(cachedRef);
|
|
1741
|
+
}
|
|
1742
|
+
const value = untracked(target);
|
|
1743
|
+
const valueIsArray = Array.isArray(value);
|
|
1744
|
+
const valueIsRecord = isRecord(value);
|
|
1745
|
+
const equalFn = (valueIsRecord || valueIsArray) &&
|
|
1746
|
+
isMutableSource &&
|
|
1747
|
+
typeof value[idx] === 'object'
|
|
1748
|
+
? () => false
|
|
1749
|
+
: undefined;
|
|
1750
|
+
const computation = valueIsRecord
|
|
1751
|
+
? derived(target, idx, { equal: equalFn })
|
|
1752
|
+
: derived(target, {
|
|
1753
|
+
from: (v) => v?.[idx],
|
|
1754
|
+
onChange: (newValue) => target.update((v) => {
|
|
1755
|
+
if (v === null || v === undefined)
|
|
1756
|
+
return v;
|
|
1757
|
+
try {
|
|
1758
|
+
v[idx] = newValue;
|
|
1759
|
+
}
|
|
1760
|
+
catch (e) {
|
|
1761
|
+
if (isDevMode())
|
|
1762
|
+
console.error(`[store] Failed to set property "${String(idx)}"`, e);
|
|
1763
|
+
}
|
|
1764
|
+
return v;
|
|
1765
|
+
}),
|
|
1766
|
+
});
|
|
1767
|
+
const proxy = Array.isArray(untracked(computation))
|
|
1768
|
+
? toArrayStore(computation, injector)
|
|
1769
|
+
: toStore(computation, injector);
|
|
1770
|
+
const ref = new WeakRef(proxy);
|
|
1771
|
+
storeCache.set(idx, ref);
|
|
1772
|
+
PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
|
|
1773
|
+
return proxy;
|
|
1774
|
+
}
|
|
1775
|
+
return Reflect.get(target, prop, receiver);
|
|
1776
|
+
},
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Converts a Signal into a deep-observable Store.
|
|
1781
|
+
* Accessing nested properties returns a derived Signal of that path.
|
|
1782
|
+
* @example
|
|
1783
|
+
* const state = store({ user: { name: 'John' } });
|
|
1784
|
+
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
1785
|
+
*/
|
|
1786
|
+
function toStore(source, injector) {
|
|
1787
|
+
if (isStore(source))
|
|
1788
|
+
return source;
|
|
1789
|
+
if (!injector)
|
|
1790
|
+
injector = inject(Injector);
|
|
1791
|
+
const writableSource = isWritableSignal(source)
|
|
1792
|
+
? source
|
|
1793
|
+
: toWritable(source, () => {
|
|
1794
|
+
// noop
|
|
1795
|
+
});
|
|
1796
|
+
const isMutableSource = isMutable(writableSource);
|
|
1797
|
+
const s = new Proxy(writableSource, {
|
|
1798
|
+
has(_, prop) {
|
|
1799
|
+
return Reflect.has(untracked(source), prop);
|
|
1800
|
+
},
|
|
1801
|
+
ownKeys() {
|
|
1802
|
+
const v = untracked(source);
|
|
1803
|
+
if (!isRecord(v))
|
|
1804
|
+
return [];
|
|
1805
|
+
return Reflect.ownKeys(v);
|
|
1806
|
+
},
|
|
1807
|
+
getPrototypeOf() {
|
|
1808
|
+
return Object.getPrototypeOf(untracked(source));
|
|
1809
|
+
},
|
|
1810
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
1811
|
+
const value = untracked(source);
|
|
1812
|
+
if (!isRecord(value) || !(prop in value))
|
|
1813
|
+
return;
|
|
1814
|
+
return {
|
|
1815
|
+
enumerable: true,
|
|
1816
|
+
configurable: true,
|
|
1817
|
+
};
|
|
1818
|
+
},
|
|
1819
|
+
get(target, prop) {
|
|
1820
|
+
if (prop === IS_STORE)
|
|
1821
|
+
return true;
|
|
1822
|
+
if (prop === 'asReadonlyStore')
|
|
1823
|
+
return () => {
|
|
1824
|
+
if (!isWritableSignal(source))
|
|
1825
|
+
return s;
|
|
1826
|
+
return untracked(() => toStore(source.asReadonly(), injector));
|
|
1827
|
+
};
|
|
1828
|
+
if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
|
|
1829
|
+
return target[prop];
|
|
1830
|
+
let storeCache = PROXY_CACHE.get(target);
|
|
1831
|
+
if (!storeCache) {
|
|
1832
|
+
storeCache = new Map();
|
|
1833
|
+
PROXY_CACHE.set(target, storeCache);
|
|
1834
|
+
}
|
|
1835
|
+
const cachedRef = storeCache.get(prop);
|
|
1836
|
+
if (cachedRef) {
|
|
1837
|
+
const cached = cachedRef.deref();
|
|
1838
|
+
if (cached)
|
|
1839
|
+
return cached;
|
|
1840
|
+
storeCache.delete(prop);
|
|
1841
|
+
PROXY_CLEANUP.unregister(cachedRef);
|
|
1842
|
+
}
|
|
1843
|
+
const value = untracked(target);
|
|
1844
|
+
const valueIsRecord = isRecord(value);
|
|
1845
|
+
const valueIsArray = Array.isArray(value);
|
|
1846
|
+
const equalFn = (valueIsRecord || valueIsArray) &&
|
|
1847
|
+
isMutableSource &&
|
|
1848
|
+
typeof value[prop] === 'object'
|
|
1849
|
+
? () => false
|
|
1850
|
+
: undefined;
|
|
1851
|
+
const computation = valueIsRecord
|
|
1852
|
+
? derived(target, prop, { equal: equalFn })
|
|
1853
|
+
: derived(target, {
|
|
1854
|
+
from: (v) => v?.[prop],
|
|
1855
|
+
onChange: (newValue) => target.update((v) => {
|
|
1856
|
+
if (v === null || v === undefined)
|
|
1857
|
+
return v;
|
|
1858
|
+
try {
|
|
1859
|
+
v[prop] = newValue;
|
|
1860
|
+
}
|
|
1861
|
+
catch (e) {
|
|
1862
|
+
if (isDevMode())
|
|
1863
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
1864
|
+
}
|
|
1865
|
+
return v;
|
|
1866
|
+
}),
|
|
1867
|
+
});
|
|
1868
|
+
const proxy = Array.isArray(untracked(computation))
|
|
1869
|
+
? toArrayStore(computation, injector)
|
|
1870
|
+
: toStore(computation, injector);
|
|
1871
|
+
const ref = new WeakRef(proxy);
|
|
1872
|
+
storeCache.set(prop, ref);
|
|
1873
|
+
PROXY_CLEANUP.register(proxy, { target, prop }, ref);
|
|
1874
|
+
return proxy;
|
|
1875
|
+
},
|
|
1876
|
+
});
|
|
1877
|
+
return s;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Creates a WritableSignalStore from a value.
|
|
1881
|
+
* @see {@link toStore}
|
|
1882
|
+
*/
|
|
1883
|
+
function store(value, opt) {
|
|
1884
|
+
return toStore(signal(value, opt), opt?.injector);
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Creates a MutableSignalStore from a value.
|
|
1888
|
+
* @see {@link toStore}
|
|
1889
|
+
*/
|
|
1890
|
+
function mutableStore(value, opt) {
|
|
1891
|
+
return toStore(mutable(value, opt), opt?.injector);
|
|
1892
|
+
}
|
|
1265
1893
|
|
|
1266
1894
|
// Internal dummy store for server-side rendering
|
|
1267
1895
|
const noopStore = {
|
|
@@ -1324,7 +1952,7 @@ const noopStore = {
|
|
|
1324
1952
|
* }
|
|
1325
1953
|
* ```
|
|
1326
1954
|
*/
|
|
1327
|
-
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, ...rest }) {
|
|
1955
|
+
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, ...rest }) {
|
|
1328
1956
|
const isServer = isPlatformServer(inject(PLATFORM_ID));
|
|
1329
1957
|
const fallbackStore = isServer ? noopStore : localStorage;
|
|
1330
1958
|
const store = providedStore ?? fallbackStore;
|
|
@@ -1338,7 +1966,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
1338
1966
|
if (found === null)
|
|
1339
1967
|
return null;
|
|
1340
1968
|
try {
|
|
1341
|
-
|
|
1969
|
+
const deserialized = deserialize(found);
|
|
1970
|
+
if (!validate(deserialized))
|
|
1971
|
+
return null;
|
|
1972
|
+
return deserialized;
|
|
1342
1973
|
}
|
|
1343
1974
|
catch (err) {
|
|
1344
1975
|
if (isDevMode())
|
|
@@ -1457,10 +2088,10 @@ class MessageBus {
|
|
|
1457
2088
|
this.channel.removeEventListener('message', listener);
|
|
1458
2089
|
this.listeners.delete(id);
|
|
1459
2090
|
}
|
|
1460
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.
|
|
1461
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.
|
|
2091
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2092
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
1462
2093
|
}
|
|
1463
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.
|
|
2094
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, decorators: [{
|
|
1464
2095
|
type: Injectable,
|
|
1465
2096
|
args: [{
|
|
1466
2097
|
providedIn: 'root',
|
|
@@ -1743,5 +2374,5 @@ function withHistory(source, opt) {
|
|
|
1743
2374
|
* Generated bundle index. Do not edit.
|
|
1744
2375
|
*/
|
|
1745
2376
|
|
|
1746
|
-
export { combineWith, debounce, debounced, derived, distinct, elementVisibility, filter, isDerivation, isMutable, map, mapArray, mediaQuery, mousePosition, mutable, nestedEffect, networkStatus, pageVisibility, pipeable, piped, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, windowSize, withHistory };
|
|
2377
|
+
export { chunked, combineWith, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, indexArray, isDerivation, isMutable, isStore, keyArray, map, mapArray, mapObject, mediaQuery, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, pageVisibility, pipeable, piped, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, sensors, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
1747
2378
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|