@mmstack/primitives 20.4.7 → 20.5.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 +21 -21
- package/README.md +1038 -777
- package/fesm2022/mmstack-primitives.mjs +925 -293
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +456 -160
- 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();
|
|
@@ -211,14 +421,15 @@ function derived(source, optOrKey, opt) {
|
|
|
211
421
|
: isMutable(source)
|
|
212
422
|
? (next) => {
|
|
213
423
|
source.mutate((cur) => {
|
|
214
|
-
cur[optOrKey] =
|
|
424
|
+
cur[optOrKey] =
|
|
425
|
+
next;
|
|
215
426
|
return cur;
|
|
216
427
|
});
|
|
217
428
|
}
|
|
218
429
|
: (next) => {
|
|
219
430
|
source.update((cur) => ({ ...cur, [optOrKey]: next }));
|
|
220
431
|
};
|
|
221
|
-
const rest = typeof optOrKey === 'object' ? optOrKey : opt;
|
|
432
|
+
const rest = typeof optOrKey === 'object' ? { ...optOrKey, ...opt } : opt;
|
|
222
433
|
const baseEqual = rest?.equal ?? Object.is;
|
|
223
434
|
let trigger = false;
|
|
224
435
|
const equal = isMutable(source)
|
|
@@ -228,7 +439,7 @@ function derived(source, optOrKey, opt) {
|
|
|
228
439
|
return baseEqual(a, b);
|
|
229
440
|
}
|
|
230
441
|
: baseEqual;
|
|
231
|
-
const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal));
|
|
442
|
+
const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal), undefined, { pure: false });
|
|
232
443
|
sig.from = from;
|
|
233
444
|
if (isMutable(source)) {
|
|
234
445
|
sig.mutate = (updater) => {
|
|
@@ -289,116 +500,8 @@ function isDerivation(sig) {
|
|
|
289
500
|
return 'from' in sig;
|
|
290
501
|
}
|
|
291
502
|
|
|
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;
|
|
503
|
+
function isWritableSignal(value) {
|
|
504
|
+
return 'set' in value && typeof value.set === 'function';
|
|
402
505
|
}
|
|
403
506
|
/**
|
|
404
507
|
* @internal
|
|
@@ -407,31 +510,45 @@ function isWritable(sig) {
|
|
|
407
510
|
* @returns
|
|
408
511
|
*/
|
|
409
512
|
function createSetter(source) {
|
|
410
|
-
if (!
|
|
513
|
+
if (!isWritableSignal(source))
|
|
411
514
|
return () => {
|
|
412
515
|
// noop;
|
|
413
516
|
};
|
|
414
517
|
if (isMutable(source))
|
|
415
518
|
return (value, index) => {
|
|
416
|
-
source.
|
|
519
|
+
source.mutate((arr) => {
|
|
417
520
|
arr[index] = value;
|
|
521
|
+
return arr;
|
|
418
522
|
});
|
|
419
523
|
};
|
|
420
524
|
return (value, index) => {
|
|
421
525
|
source.update((arr) => arr.map((v, i) => (i === index ? value : v)));
|
|
422
526
|
};
|
|
423
527
|
}
|
|
424
|
-
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Helper to create the derived signal for a specific index.
|
|
531
|
+
* Extracts the cast logic to keep the main loop clean.
|
|
532
|
+
*/
|
|
533
|
+
function createItemSignal(source, index, setter, opt) {
|
|
534
|
+
return derived(
|
|
535
|
+
// We cast to any/Mutable to satisfy the overload signature,
|
|
536
|
+
// but 'derived' internally checks isMutable() for safety.
|
|
537
|
+
source, {
|
|
538
|
+
from: (src) => src[index],
|
|
539
|
+
onChange: (value) => setter(value, index),
|
|
540
|
+
}, opt);
|
|
541
|
+
}
|
|
542
|
+
function indexArray(source, map, opt = {}) {
|
|
425
543
|
const data = isSignal(source) ? source : computed(source);
|
|
426
544
|
const len = computed(() => data().length, ...(ngDevMode ? [{ debugName: "len" }] : []));
|
|
427
545
|
const setter = createSetter(data);
|
|
428
|
-
const
|
|
429
|
-
const writableData = isWritable(data)
|
|
546
|
+
const writableData = isWritableSignal(data)
|
|
430
547
|
? data
|
|
431
548
|
: toWritable(data, () => {
|
|
432
549
|
// noop
|
|
433
550
|
});
|
|
434
|
-
if (
|
|
551
|
+
if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
|
|
435
552
|
opt.equal = (a, b) => {
|
|
436
553
|
if (a !== b)
|
|
437
554
|
return false; // actually check primitives and references
|
|
@@ -442,175 +559,201 @@ function mapArray(source, map, options) {
|
|
|
442
559
|
source: () => len(),
|
|
443
560
|
computation: (len, prev) => {
|
|
444
561
|
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
|
-
});
|
|
562
|
+
return Array.from({ length: len }, (_, i) => map(createItemSignal(writableData, i, setter, opt), i));
|
|
453
563
|
if (len === prev.value.length)
|
|
454
564
|
return prev.value;
|
|
455
565
|
if (len < prev.value.length) {
|
|
456
|
-
const slice = prev.value.slice(0, len);
|
|
457
566
|
if (opt.onDestroy) {
|
|
458
567
|
for (let i = len; i < prev.value.length; i++) {
|
|
459
|
-
opt.onDestroy
|
|
568
|
+
opt.onDestroy(prev.value[i]);
|
|
460
569
|
}
|
|
461
570
|
}
|
|
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;
|
|
571
|
+
return prev.value.slice(0, len);
|
|
475
572
|
}
|
|
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
|
-
});
|
|
573
|
+
const next = prev.value.slice();
|
|
574
|
+
for (let i = prev.value.length; i < len; i++)
|
|
575
|
+
next[i] = map(createItemSignal(writableData, i, setter, opt), i);
|
|
576
|
+
return next;
|
|
577
|
+
},
|
|
578
|
+
equal: (a, b) => a.length === b.length,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* @deprecated use indexArray instead
|
|
583
|
+
*/
|
|
584
|
+
const mapArray = indexArray;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Reactively maps items from a source array to a new array by value (identity).
|
|
588
|
+
*
|
|
589
|
+
* similar to `Array.prototype.map`, but:
|
|
590
|
+
* 1. The `mapFn` receives the `index` as a Signal.
|
|
591
|
+
* 2. If an item in the `source` array moves to a new position, the *result* of the map function is reused and moved.
|
|
592
|
+
* The `index` signal is updated to the new index.
|
|
593
|
+
* 3. The `mapFn` is only run for *new* items.
|
|
594
|
+
*
|
|
595
|
+
* This is useful for building efficient lists where DOM nodes or heavy instances should be reused
|
|
596
|
+
* when the list is reordered.
|
|
597
|
+
*
|
|
598
|
+
* @param source A `Signal<T[]>` or a function returning `T[]`.
|
|
599
|
+
* @param mapFn The mapping function. Receives the item and its index as a Signal.
|
|
600
|
+
* @param options Optional configuration:
|
|
601
|
+
* - `onDestroy`: A callback invoked when a mapped item is removed from the array.
|
|
602
|
+
* @returns A `Signal<U[]>` containing the mapped array.
|
|
603
|
+
*/
|
|
604
|
+
function keyArray(source, mapFn, options = {}) {
|
|
605
|
+
const sourceSignal = isSignal(source) ? source : computed(source);
|
|
606
|
+
const items = [];
|
|
607
|
+
let mapped = [];
|
|
608
|
+
const indexes = [];
|
|
609
|
+
const getKey = options.key || ((v) => v);
|
|
610
|
+
const newIndices = new Map();
|
|
611
|
+
const temp = [];
|
|
612
|
+
const tempIndexes = [];
|
|
613
|
+
const newIndicesNext = [];
|
|
614
|
+
const newIndexesCache = new Array();
|
|
615
|
+
return computed(() => {
|
|
616
|
+
const newItems = sourceSignal() || [];
|
|
617
|
+
return untracked(() => {
|
|
618
|
+
let i;
|
|
619
|
+
let j;
|
|
620
|
+
const newLen = newItems.length;
|
|
621
|
+
const len = items.length;
|
|
622
|
+
const newMapped = new Array(newLen);
|
|
623
|
+
const newIndexes = newIndexesCache;
|
|
624
|
+
newIndexes.length = 0;
|
|
625
|
+
newIndexes.length = newLen;
|
|
626
|
+
let start;
|
|
627
|
+
let end;
|
|
628
|
+
let newEnd;
|
|
629
|
+
let item;
|
|
630
|
+
let key;
|
|
631
|
+
if (newLen === 0) {
|
|
632
|
+
if (len !== 0) {
|
|
633
|
+
if (options.onDestroy) {
|
|
634
|
+
for (let k = 0; k < len; k++)
|
|
635
|
+
options.onDestroy(mapped[k]);
|
|
636
|
+
}
|
|
637
|
+
items.length = 0;
|
|
638
|
+
mapped = [];
|
|
639
|
+
indexes.length = 0;
|
|
640
|
+
}
|
|
641
|
+
return mapped;
|
|
594
642
|
}
|
|
595
|
-
|
|
596
|
-
|
|
643
|
+
if (len === 0) {
|
|
644
|
+
for (j = 0; j < newLen; j++) {
|
|
645
|
+
item = newItems[j];
|
|
646
|
+
items[j] = item;
|
|
647
|
+
const indexSignal = signal(j, ...(ngDevMode ? [{ debugName: "indexSignal" }] : []));
|
|
648
|
+
newIndexes[j] = indexSignal;
|
|
649
|
+
newMapped[j] = mapFn(item, indexSignal);
|
|
650
|
+
}
|
|
597
651
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
652
|
+
else {
|
|
653
|
+
newIndices.clear();
|
|
654
|
+
temp.length = 0;
|
|
655
|
+
tempIndexes.length = 0;
|
|
656
|
+
newIndicesNext.length = 0;
|
|
657
|
+
for (start = 0, end = Math.min(len, newLen); start < end && getKey(items[start]) === getKey(newItems[start]); start++) {
|
|
658
|
+
newMapped[start] = mapped[start];
|
|
659
|
+
newIndexes[start] = indexes[start];
|
|
660
|
+
}
|
|
661
|
+
for (end = len - 1, newEnd = newLen - 1; end >= start &&
|
|
662
|
+
newEnd >= start &&
|
|
663
|
+
getKey(items[end]) === getKey(newItems[newEnd]); end--, newEnd--) {
|
|
664
|
+
temp[newEnd] = mapped[end];
|
|
665
|
+
tempIndexes[newEnd] = indexes[end];
|
|
666
|
+
}
|
|
667
|
+
for (j = newEnd; j >= start; j--) {
|
|
668
|
+
item = newItems[j];
|
|
669
|
+
key = getKey(item);
|
|
670
|
+
i = newIndices.get(key);
|
|
671
|
+
newIndicesNext[j] = i === undefined ? -1 : i;
|
|
672
|
+
newIndices.set(key, j);
|
|
673
|
+
}
|
|
674
|
+
for (i = start; i <= end; i++) {
|
|
675
|
+
item = items[i];
|
|
676
|
+
key = getKey(item);
|
|
677
|
+
j = newIndices.get(key);
|
|
678
|
+
if (j !== undefined && j !== -1) {
|
|
679
|
+
temp[j] = mapped[i];
|
|
680
|
+
tempIndexes[j] = indexes[i];
|
|
681
|
+
j = newIndicesNext[j];
|
|
682
|
+
newIndices.set(key, j);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
if (options.onDestroy)
|
|
686
|
+
options.onDestroy(mapped[i]);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// 2) Set all new values
|
|
690
|
+
for (j = start; j < newLen; j++) {
|
|
691
|
+
if (temp[j] !== undefined) {
|
|
692
|
+
newMapped[j] = temp[j];
|
|
693
|
+
newIndexes[j] = tempIndexes[j];
|
|
694
|
+
newIndexes[j].set(j);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
const indexSignal = signal(j, ...(ngDevMode ? [{ debugName: "indexSignal" }] : []));
|
|
698
|
+
newIndexes[j] = indexSignal;
|
|
699
|
+
newMapped[j] = mapFn(newItems[j], indexSignal);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
items.length = newLen;
|
|
703
|
+
for (let k = 0; k < newLen; k++)
|
|
704
|
+
items[k] = newItems[k];
|
|
705
|
+
}
|
|
706
|
+
mapped = newMapped;
|
|
707
|
+
indexes.length = newLen;
|
|
708
|
+
for (let k = 0; k < newLen; k++)
|
|
709
|
+
indexes[k] = newIndexes[k];
|
|
710
|
+
return mapped;
|
|
603
711
|
});
|
|
604
712
|
});
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function pooledKeys(src) {
|
|
716
|
+
const aBuf = new Set();
|
|
717
|
+
const bBuf = new Set();
|
|
718
|
+
let active = aBuf;
|
|
719
|
+
let spare = bBuf;
|
|
720
|
+
return computed(() => {
|
|
721
|
+
const val = src();
|
|
722
|
+
spare.clear();
|
|
723
|
+
for (const k in val)
|
|
724
|
+
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
725
|
+
spare.add(k);
|
|
726
|
+
if (active.size === spare.size && active.isSubsetOf(spare))
|
|
727
|
+
return active;
|
|
728
|
+
const temp = active;
|
|
729
|
+
active = spare;
|
|
730
|
+
spare = temp;
|
|
731
|
+
return active;
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function mapObject(source, mapFn, options = {}) {
|
|
735
|
+
const src = isSignal(source) ? source : computed(source);
|
|
736
|
+
const writable = (isWritableSignal(src)
|
|
737
|
+
? src
|
|
738
|
+
: toWritable(src, () => {
|
|
739
|
+
// noop
|
|
740
|
+
})); // maximal overload internally
|
|
741
|
+
return linkedSignal({
|
|
742
|
+
source: pooledKeys(src),
|
|
743
|
+
computation: (next, prev) => {
|
|
744
|
+
const nextObj = {};
|
|
745
|
+
for (const k of next)
|
|
746
|
+
nextObj[k] =
|
|
747
|
+
prev && prev.source.has(k)
|
|
748
|
+
? prev.value[k]
|
|
749
|
+
: mapFn(k, derived(writable, k));
|
|
750
|
+
if (options.onDestroy && prev && prev.source.size)
|
|
751
|
+
for (const k of prev.source)
|
|
752
|
+
if (!next.has(k))
|
|
753
|
+
options.onDestroy(prev.value[k]);
|
|
754
|
+
return nextObj;
|
|
610
755
|
},
|
|
611
|
-
};
|
|
612
|
-
parent?.children.add(ref);
|
|
613
|
-
return ref;
|
|
756
|
+
}).asReadonly();
|
|
614
757
|
}
|
|
615
758
|
|
|
616
759
|
/** Project with optional equality. Pure & sync. */
|
|
@@ -698,6 +841,206 @@ function piped(initial, opt) {
|
|
|
698
841
|
return pipeable(signal(initial, opt));
|
|
699
842
|
}
|
|
700
843
|
|
|
844
|
+
function observerSupported$1() {
|
|
845
|
+
return typeof ResizeObserver !== 'undefined';
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Creates a read-only signal that tracks the size of a target DOM element.
|
|
849
|
+
*
|
|
850
|
+
* By default, it observes the `border-box` size to align with `getBoundingClientRect()`,
|
|
851
|
+
* which is used to provide a synchronous initial value if possible.
|
|
852
|
+
*
|
|
853
|
+
* @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
|
|
854
|
+
* @param options Optional configuration including `box` (defaults to 'border-box') and `debugName`.
|
|
855
|
+
* @returns A `Signal<ElementSize | undefined>`.
|
|
856
|
+
*
|
|
857
|
+
* @example
|
|
858
|
+
* ```ts
|
|
859
|
+
* const size = elementSize(elementRef);
|
|
860
|
+
* effect(() => {
|
|
861
|
+
* console.log('Size:', size()?.width, size()?.height);
|
|
862
|
+
* });
|
|
863
|
+
* ```
|
|
864
|
+
*/
|
|
865
|
+
function elementSize(target = inject(ElementRef), opt) {
|
|
866
|
+
const getElement = () => {
|
|
867
|
+
if (isSignal(target)) {
|
|
868
|
+
try {
|
|
869
|
+
const val = target();
|
|
870
|
+
return val instanceof ElementRef ? val.nativeElement : val;
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return target instanceof ElementRef ? target.nativeElement : target;
|
|
877
|
+
};
|
|
878
|
+
const resolveInitialValue = () => {
|
|
879
|
+
if (!observerSupported$1())
|
|
880
|
+
return undefined;
|
|
881
|
+
const el = getElement();
|
|
882
|
+
if (el && el.getBoundingClientRect) {
|
|
883
|
+
const rect = el.getBoundingClientRect();
|
|
884
|
+
return { width: rect.width, height: rect.height };
|
|
885
|
+
}
|
|
886
|
+
return undefined;
|
|
887
|
+
};
|
|
888
|
+
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
889
|
+
return computed(() => untracked(resolveInitialValue), {
|
|
890
|
+
debugName: opt?.debugName,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
const state = signal(untracked(resolveInitialValue), {
|
|
894
|
+
debugName: opt?.debugName,
|
|
895
|
+
equal: (a, b) => a?.width === b?.width && a?.height === b?.height,
|
|
896
|
+
});
|
|
897
|
+
const targetSignal = isSignal(target) ? target : computed(() => target);
|
|
898
|
+
effect((cleanup) => {
|
|
899
|
+
const el = targetSignal();
|
|
900
|
+
if (el) {
|
|
901
|
+
const nativeEl = el instanceof ElementRef ? el.nativeElement : el;
|
|
902
|
+
const rect = nativeEl.getBoundingClientRect();
|
|
903
|
+
untracked(() => state.set({ width: rect.width, height: rect.height }));
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
untracked(() => state.set(undefined));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (!observerSupported$1())
|
|
910
|
+
return;
|
|
911
|
+
let observer = null;
|
|
912
|
+
observer = new ResizeObserver(([entry]) => {
|
|
913
|
+
let width = 0;
|
|
914
|
+
let height = 0;
|
|
915
|
+
const boxOption = opt?.box ?? 'border-box';
|
|
916
|
+
if (boxOption === 'border-box' && entry.borderBoxSize?.length > 0) {
|
|
917
|
+
const size = entry.borderBoxSize[0];
|
|
918
|
+
width = size.inlineSize;
|
|
919
|
+
height = size.blockSize;
|
|
920
|
+
}
|
|
921
|
+
else if (boxOption === 'content-box' &&
|
|
922
|
+
entry.contentBoxSize?.length > 0) {
|
|
923
|
+
width = entry.contentBoxSize[0].inlineSize;
|
|
924
|
+
height = entry.contentBoxSize[0].blockSize;
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
width = entry.contentRect.width;
|
|
928
|
+
height = entry.contentRect.height;
|
|
929
|
+
}
|
|
930
|
+
state.set({ width, height });
|
|
931
|
+
});
|
|
932
|
+
observer.observe(el instanceof ElementRef ? el.nativeElement : el, {
|
|
933
|
+
box: opt?.box ?? 'border-box',
|
|
934
|
+
});
|
|
935
|
+
cleanup(() => {
|
|
936
|
+
observer?.disconnect();
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
return state.asReadonly();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function observerSupported() {
|
|
943
|
+
return typeof IntersectionObserver !== 'undefined';
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Creates a read-only signal that tracks the intersection status of a target DOM element
|
|
947
|
+
* with the viewport or a specified root element, using the `IntersectionObserver` API.
|
|
948
|
+
*
|
|
949
|
+
* It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one,
|
|
950
|
+
* allowing for dynamic targets.
|
|
951
|
+
*
|
|
952
|
+
* @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
|
|
953
|
+
* If the signal resolves to `null`, observation stops.
|
|
954
|
+
* @param options Optional `IntersectionObserverInit` options (e.g., `root`, `rootMargin`, `threshold`)
|
|
955
|
+
* and an optional `debugName`.
|
|
956
|
+
* @returns A `Signal<IntersectionObserverEntry | undefined>`. It emits `undefined` initially,
|
|
957
|
+
* on the server, or if the target is `null`. Otherwise, it emits the latest
|
|
958
|
+
* `IntersectionObserverEntry`. Consumers can derive a boolean `isVisible` from
|
|
959
|
+
* this entry's `isIntersecting` property.
|
|
960
|
+
*
|
|
961
|
+
* @example
|
|
962
|
+
* ```ts
|
|
963
|
+
* import { Component, effect, ElementRef, viewChild } from '@angular/core';
|
|
964
|
+
* import { elementVisibility } from '@mmstack/primitives';
|
|
965
|
+
* import { computed } from '@angular/core'; // For derived boolean
|
|
966
|
+
*
|
|
967
|
+
* @Component({
|
|
968
|
+
* selector: 'app-lazy-image',
|
|
969
|
+
* template: `
|
|
970
|
+
* <div #imageContainer style="height: 200px; border: 1px dashed grey;">
|
|
971
|
+
* @if (isVisible()) {
|
|
972
|
+
* <img src="your-image-url.jpg" alt="Lazy loaded image" />
|
|
973
|
+
* <p>Image is VISIBLE!</p>
|
|
974
|
+
* } @else {
|
|
975
|
+
* <p>Scroll down to see the image...</p>
|
|
976
|
+
* }
|
|
977
|
+
* </div>
|
|
978
|
+
* `
|
|
979
|
+
* })
|
|
980
|
+
* export class LazyImageComponent {
|
|
981
|
+
* readonly imageContainer = viewChild.required<ElementRef<HTMLDivElement>>('imageContainer');
|
|
982
|
+
*
|
|
983
|
+
* // Observe the element, get the full IntersectionObserverEntry
|
|
984
|
+
* readonly intersectionEntry = elementVisibility(this.imageContainer);
|
|
985
|
+
*
|
|
986
|
+
* // Derive a simple boolean for visibility
|
|
987
|
+
* readonly isVisible = computed(() => this.intersectionEntry()?.isIntersecting ?? false);
|
|
988
|
+
*
|
|
989
|
+
* constructor() {
|
|
990
|
+
* effect(() => {
|
|
991
|
+
* console.log('Intersection Entry:', this.intersectionEntry());
|
|
992
|
+
* console.log('Is Visible:', this.isVisible());
|
|
993
|
+
* });
|
|
994
|
+
* }
|
|
995
|
+
* }
|
|
996
|
+
* ```
|
|
997
|
+
*/
|
|
998
|
+
function elementVisibility(target = inject(ElementRef), opt) {
|
|
999
|
+
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1000
|
+
const base = computed(() => undefined, {
|
|
1001
|
+
debugName: opt?.debugName,
|
|
1002
|
+
});
|
|
1003
|
+
base.visible = computed(() => false, ...(ngDevMode ? [{ debugName: "visible" }] : []));
|
|
1004
|
+
return base;
|
|
1005
|
+
}
|
|
1006
|
+
const state = signal(undefined, {
|
|
1007
|
+
debugName: opt?.debugName,
|
|
1008
|
+
equal: (a, b) => {
|
|
1009
|
+
if (!a && !b)
|
|
1010
|
+
return true;
|
|
1011
|
+
if (!a || !b)
|
|
1012
|
+
return false;
|
|
1013
|
+
return (a.target === b.target &&
|
|
1014
|
+
a.isIntersecting === b.isIntersecting &&
|
|
1015
|
+
a.intersectionRatio === b.intersectionRatio &&
|
|
1016
|
+
a.boundingClientRect.top === b.boundingClientRect.top &&
|
|
1017
|
+
a.boundingClientRect.left === b.boundingClientRect.left &&
|
|
1018
|
+
a.boundingClientRect.width === b.boundingClientRect.width &&
|
|
1019
|
+
a.boundingClientRect.height === b.boundingClientRect.height);
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
const targetSignal = isSignal(target) ? target : computed(() => target);
|
|
1023
|
+
effect((cleanup) => {
|
|
1024
|
+
const el = targetSignal();
|
|
1025
|
+
if (!el)
|
|
1026
|
+
return state.set(undefined);
|
|
1027
|
+
let observer = null;
|
|
1028
|
+
observer = new IntersectionObserver(([entry]) => state.set(entry), opt);
|
|
1029
|
+
observer.observe(el instanceof ElementRef ? el.nativeElement : el);
|
|
1030
|
+
cleanup(() => {
|
|
1031
|
+
observer?.disconnect();
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
const base = state.asReadonly();
|
|
1035
|
+
base.visible = computed(() => {
|
|
1036
|
+
const s = state();
|
|
1037
|
+
if (!s)
|
|
1038
|
+
return false;
|
|
1039
|
+
return s.isIntersecting;
|
|
1040
|
+
}, ...(ngDevMode ? [{ debugName: "visible" }] : []));
|
|
1041
|
+
return base;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
701
1044
|
/**
|
|
702
1045
|
* Creates a read-only signal that reactively tracks whether a CSS media query
|
|
703
1046
|
* string currently matches.
|
|
@@ -869,8 +1212,7 @@ function throttle(source, opt) {
|
|
|
869
1212
|
catch {
|
|
870
1213
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
871
1214
|
}
|
|
872
|
-
const
|
|
873
|
-
updateSourceAction();
|
|
1215
|
+
const tick = () => {
|
|
874
1216
|
if (timeout)
|
|
875
1217
|
return;
|
|
876
1218
|
timeout = setTimeout(() => {
|
|
@@ -879,10 +1221,12 @@ function throttle(source, opt) {
|
|
|
879
1221
|
}, ms);
|
|
880
1222
|
};
|
|
881
1223
|
const set = (value) => {
|
|
882
|
-
|
|
1224
|
+
source.set(value);
|
|
1225
|
+
tick();
|
|
883
1226
|
};
|
|
884
1227
|
const update = (fn) => {
|
|
885
|
-
|
|
1228
|
+
source.update(fn);
|
|
1229
|
+
tick();
|
|
886
1230
|
};
|
|
887
1231
|
const writable = toWritable(computed(() => {
|
|
888
1232
|
trigger();
|
|
@@ -1250,18 +1594,303 @@ function sensor(type, options) {
|
|
|
1250
1594
|
return networkStatus(options?.debugName);
|
|
1251
1595
|
case 'pageVisibility':
|
|
1252
1596
|
return pageVisibility(options?.debugName);
|
|
1597
|
+
case 'darkMode':
|
|
1253
1598
|
case 'dark-mode':
|
|
1254
1599
|
return prefersDarkMode(options?.debugName);
|
|
1600
|
+
case 'reducedMotion':
|
|
1255
1601
|
case 'reduced-motion':
|
|
1256
1602
|
return prefersReducedMotion(options?.debugName);
|
|
1603
|
+
case 'mediaQuery': {
|
|
1604
|
+
const opt = options;
|
|
1605
|
+
return mediaQuery(opt.query, opt.debugName);
|
|
1606
|
+
}
|
|
1257
1607
|
case 'windowSize':
|
|
1258
1608
|
return windowSize(options);
|
|
1259
1609
|
case 'scrollPosition':
|
|
1260
1610
|
return scrollPosition(options);
|
|
1611
|
+
case 'elementVisibility': {
|
|
1612
|
+
const opt = options;
|
|
1613
|
+
return elementVisibility(opt.target, opt);
|
|
1614
|
+
}
|
|
1615
|
+
case 'elementSize': {
|
|
1616
|
+
const opt = options;
|
|
1617
|
+
return elementSize(opt.target, opt);
|
|
1618
|
+
}
|
|
1261
1619
|
default:
|
|
1262
1620
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
1263
1621
|
}
|
|
1264
1622
|
}
|
|
1623
|
+
function sensors(track, opt) {
|
|
1624
|
+
return track.reduce((result, key) => {
|
|
1625
|
+
result[key] = sensor(key, opt?.[key]);
|
|
1626
|
+
return result;
|
|
1627
|
+
}, {});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const IS_STORE = Symbol('MMSTACK::IS_STORE');
|
|
1631
|
+
const PROXY_CACHE = new WeakMap();
|
|
1632
|
+
const SIGNAL_FN_PROP = new Set([
|
|
1633
|
+
'set',
|
|
1634
|
+
'update',
|
|
1635
|
+
'mutate',
|
|
1636
|
+
'inline',
|
|
1637
|
+
'asReadonly',
|
|
1638
|
+
]);
|
|
1639
|
+
const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
|
|
1640
|
+
const storeCache = PROXY_CACHE.get(target);
|
|
1641
|
+
if (storeCache)
|
|
1642
|
+
storeCache.delete(prop);
|
|
1643
|
+
});
|
|
1644
|
+
/**
|
|
1645
|
+
* @internal
|
|
1646
|
+
* Validates whether a value is a Signal Store.
|
|
1647
|
+
*/
|
|
1648
|
+
function isStore(value) {
|
|
1649
|
+
return (typeof value === 'function' &&
|
|
1650
|
+
value !== null &&
|
|
1651
|
+
value[IS_STORE] === true);
|
|
1652
|
+
}
|
|
1653
|
+
function isIndexProp(prop) {
|
|
1654
|
+
return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
|
|
1655
|
+
}
|
|
1656
|
+
function isRecord(value) {
|
|
1657
|
+
if (value === null || typeof value !== 'object')
|
|
1658
|
+
return false;
|
|
1659
|
+
const proto = Object.getPrototypeOf(value);
|
|
1660
|
+
return proto === Object.prototype || proto === null;
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* @internal
|
|
1664
|
+
* Makes an array store
|
|
1665
|
+
*/
|
|
1666
|
+
function toArrayStore(source, injector) {
|
|
1667
|
+
if (isStore(source))
|
|
1668
|
+
return source;
|
|
1669
|
+
const isMutableSource = isMutable(source);
|
|
1670
|
+
const lengthSignal = computed(() => {
|
|
1671
|
+
const v = source();
|
|
1672
|
+
if (!Array.isArray(v))
|
|
1673
|
+
return 0;
|
|
1674
|
+
return v.length;
|
|
1675
|
+
}, ...(ngDevMode ? [{ debugName: "lengthSignal" }] : []));
|
|
1676
|
+
return new Proxy(source, {
|
|
1677
|
+
has(_, prop) {
|
|
1678
|
+
if (prop === 'length')
|
|
1679
|
+
return true;
|
|
1680
|
+
if (isIndexProp(prop)) {
|
|
1681
|
+
const idx = +prop;
|
|
1682
|
+
return idx >= 0 && idx < untracked(lengthSignal);
|
|
1683
|
+
}
|
|
1684
|
+
return Reflect.has(untracked(source), prop);
|
|
1685
|
+
},
|
|
1686
|
+
ownKeys() {
|
|
1687
|
+
const v = untracked(source);
|
|
1688
|
+
if (!Array.isArray(v))
|
|
1689
|
+
return [];
|
|
1690
|
+
const len = v.length;
|
|
1691
|
+
const arr = new Array(len + 1);
|
|
1692
|
+
for (let i = 0; i < len; i++) {
|
|
1693
|
+
arr[i] = String(i);
|
|
1694
|
+
}
|
|
1695
|
+
arr[len] = 'length';
|
|
1696
|
+
return arr;
|
|
1697
|
+
},
|
|
1698
|
+
getPrototypeOf() {
|
|
1699
|
+
return Array.prototype;
|
|
1700
|
+
},
|
|
1701
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
1702
|
+
const v = untracked(source);
|
|
1703
|
+
if (!Array.isArray(v))
|
|
1704
|
+
return;
|
|
1705
|
+
if (prop === 'length' ||
|
|
1706
|
+
(typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
|
|
1707
|
+
return {
|
|
1708
|
+
enumerable: true,
|
|
1709
|
+
configurable: true, // Required for proxies to dynamic targets
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1713
|
+
},
|
|
1714
|
+
get(target, prop, receiver) {
|
|
1715
|
+
if (prop === IS_STORE)
|
|
1716
|
+
return true;
|
|
1717
|
+
if (prop === 'length')
|
|
1718
|
+
return lengthSignal;
|
|
1719
|
+
if (prop === Symbol.iterator) {
|
|
1720
|
+
return function* () {
|
|
1721
|
+
for (let i = 0; i < untracked(lengthSignal); i++) {
|
|
1722
|
+
yield receiver[i];
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
|
|
1727
|
+
return target[prop];
|
|
1728
|
+
if (isIndexProp(prop)) {
|
|
1729
|
+
const idx = +prop;
|
|
1730
|
+
let storeCache = PROXY_CACHE.get(target);
|
|
1731
|
+
if (!storeCache) {
|
|
1732
|
+
storeCache = new Map();
|
|
1733
|
+
PROXY_CACHE.set(target, storeCache);
|
|
1734
|
+
}
|
|
1735
|
+
const cachedRef = storeCache.get(idx);
|
|
1736
|
+
if (cachedRef) {
|
|
1737
|
+
const cached = cachedRef.deref();
|
|
1738
|
+
if (cached)
|
|
1739
|
+
return cached;
|
|
1740
|
+
storeCache.delete(idx);
|
|
1741
|
+
PROXY_CLEANUP.unregister(cachedRef);
|
|
1742
|
+
}
|
|
1743
|
+
const value = untracked(target);
|
|
1744
|
+
const valueIsArray = Array.isArray(value);
|
|
1745
|
+
const valueIsRecord = isRecord(value);
|
|
1746
|
+
const equalFn = (valueIsRecord || valueIsArray) &&
|
|
1747
|
+
isMutableSource &&
|
|
1748
|
+
typeof value[idx] === 'object'
|
|
1749
|
+
? () => false
|
|
1750
|
+
: undefined;
|
|
1751
|
+
const computation = valueIsRecord
|
|
1752
|
+
? derived(target, idx, { equal: equalFn })
|
|
1753
|
+
: derived(target, {
|
|
1754
|
+
from: (v) => v?.[idx],
|
|
1755
|
+
onChange: (newValue) => target.update((v) => {
|
|
1756
|
+
if (v === null || v === undefined)
|
|
1757
|
+
return v;
|
|
1758
|
+
try {
|
|
1759
|
+
v[idx] = newValue;
|
|
1760
|
+
}
|
|
1761
|
+
catch (e) {
|
|
1762
|
+
if (isDevMode())
|
|
1763
|
+
console.error(`[store] Failed to set property "${String(idx)}"`, e);
|
|
1764
|
+
}
|
|
1765
|
+
return v;
|
|
1766
|
+
}),
|
|
1767
|
+
});
|
|
1768
|
+
const proxy = Array.isArray(untracked(computation))
|
|
1769
|
+
? toArrayStore(computation, injector)
|
|
1770
|
+
: toStore(computation, injector);
|
|
1771
|
+
const ref = new WeakRef(proxy);
|
|
1772
|
+
storeCache.set(idx, ref);
|
|
1773
|
+
PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
|
|
1774
|
+
return proxy;
|
|
1775
|
+
}
|
|
1776
|
+
return Reflect.get(target, prop, receiver);
|
|
1777
|
+
},
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Converts a Signal into a deep-observable Store.
|
|
1782
|
+
* Accessing nested properties returns a derived Signal of that path.
|
|
1783
|
+
* @example
|
|
1784
|
+
* const state = store({ user: { name: 'John' } });
|
|
1785
|
+
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
1786
|
+
*/
|
|
1787
|
+
function toStore(source, injector) {
|
|
1788
|
+
if (isStore(source))
|
|
1789
|
+
return source;
|
|
1790
|
+
if (!injector)
|
|
1791
|
+
injector = inject(Injector);
|
|
1792
|
+
const writableSource = isWritableSignal(source)
|
|
1793
|
+
? source
|
|
1794
|
+
: toWritable(source, () => {
|
|
1795
|
+
// noop
|
|
1796
|
+
});
|
|
1797
|
+
const isMutableSource = isMutable(writableSource);
|
|
1798
|
+
const s = new Proxy(writableSource, {
|
|
1799
|
+
has(_, prop) {
|
|
1800
|
+
return Reflect.has(untracked(source), prop);
|
|
1801
|
+
},
|
|
1802
|
+
ownKeys() {
|
|
1803
|
+
const v = untracked(source);
|
|
1804
|
+
if (!isRecord(v))
|
|
1805
|
+
return [];
|
|
1806
|
+
return Reflect.ownKeys(v);
|
|
1807
|
+
},
|
|
1808
|
+
getPrototypeOf() {
|
|
1809
|
+
return Object.getPrototypeOf(untracked(source));
|
|
1810
|
+
},
|
|
1811
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
1812
|
+
const value = untracked(source);
|
|
1813
|
+
if (!isRecord(value) || !(prop in value))
|
|
1814
|
+
return;
|
|
1815
|
+
return {
|
|
1816
|
+
enumerable: true,
|
|
1817
|
+
configurable: true,
|
|
1818
|
+
};
|
|
1819
|
+
},
|
|
1820
|
+
get(target, prop) {
|
|
1821
|
+
if (prop === IS_STORE)
|
|
1822
|
+
return true;
|
|
1823
|
+
if (prop === 'asReadonlyStore')
|
|
1824
|
+
return () => {
|
|
1825
|
+
if (!isWritableSignal(source))
|
|
1826
|
+
return s;
|
|
1827
|
+
return untracked(() => toStore(source.asReadonly(), injector));
|
|
1828
|
+
};
|
|
1829
|
+
if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
|
|
1830
|
+
return target[prop];
|
|
1831
|
+
let storeCache = PROXY_CACHE.get(target);
|
|
1832
|
+
if (!storeCache) {
|
|
1833
|
+
storeCache = new Map();
|
|
1834
|
+
PROXY_CACHE.set(target, storeCache);
|
|
1835
|
+
}
|
|
1836
|
+
const cachedRef = storeCache.get(prop);
|
|
1837
|
+
if (cachedRef) {
|
|
1838
|
+
const cached = cachedRef.deref();
|
|
1839
|
+
if (cached)
|
|
1840
|
+
return cached;
|
|
1841
|
+
storeCache.delete(prop);
|
|
1842
|
+
PROXY_CLEANUP.unregister(cachedRef);
|
|
1843
|
+
}
|
|
1844
|
+
const value = untracked(target);
|
|
1845
|
+
const valueIsRecord = isRecord(value);
|
|
1846
|
+
const valueIsArray = Array.isArray(value);
|
|
1847
|
+
const equalFn = (valueIsRecord || valueIsArray) &&
|
|
1848
|
+
isMutableSource &&
|
|
1849
|
+
typeof value[prop] === 'object'
|
|
1850
|
+
? () => false
|
|
1851
|
+
: undefined;
|
|
1852
|
+
const computation = valueIsRecord
|
|
1853
|
+
? derived(target, prop, { equal: equalFn })
|
|
1854
|
+
: derived(target, {
|
|
1855
|
+
from: (v) => v?.[prop],
|
|
1856
|
+
onChange: (newValue) => target.update((v) => {
|
|
1857
|
+
if (v === null || v === undefined)
|
|
1858
|
+
return v;
|
|
1859
|
+
try {
|
|
1860
|
+
v[prop] = newValue;
|
|
1861
|
+
}
|
|
1862
|
+
catch (e) {
|
|
1863
|
+
if (isDevMode())
|
|
1864
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
1865
|
+
}
|
|
1866
|
+
return v;
|
|
1867
|
+
}),
|
|
1868
|
+
});
|
|
1869
|
+
const proxy = Array.isArray(untracked(computation))
|
|
1870
|
+
? toArrayStore(computation, injector)
|
|
1871
|
+
: toStore(computation, injector);
|
|
1872
|
+
const ref = new WeakRef(proxy);
|
|
1873
|
+
storeCache.set(prop, ref);
|
|
1874
|
+
PROXY_CLEANUP.register(proxy, { target, prop }, ref);
|
|
1875
|
+
return proxy;
|
|
1876
|
+
},
|
|
1877
|
+
});
|
|
1878
|
+
return s;
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Creates a WritableSignalStore from a value.
|
|
1882
|
+
* @see {@link toStore}
|
|
1883
|
+
*/
|
|
1884
|
+
function store(value, opt) {
|
|
1885
|
+
return toStore(signal(value, opt), opt?.injector);
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Creates a MutableSignalStore from a value.
|
|
1889
|
+
* @see {@link toStore}
|
|
1890
|
+
*/
|
|
1891
|
+
function mutableStore(value, opt) {
|
|
1892
|
+
return toStore(mutable(value, opt), opt?.injector);
|
|
1893
|
+
}
|
|
1265
1894
|
|
|
1266
1895
|
// Internal dummy store for server-side rendering
|
|
1267
1896
|
const noopStore = {
|
|
@@ -1324,7 +1953,7 @@ const noopStore = {
|
|
|
1324
1953
|
* }
|
|
1325
1954
|
* ```
|
|
1326
1955
|
*/
|
|
1327
|
-
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, ...rest }) {
|
|
1956
|
+
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
1957
|
const isServer = isPlatformServer(inject(PLATFORM_ID));
|
|
1329
1958
|
const fallbackStore = isServer ? noopStore : localStorage;
|
|
1330
1959
|
const store = providedStore ?? fallbackStore;
|
|
@@ -1338,7 +1967,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
1338
1967
|
if (found === null)
|
|
1339
1968
|
return null;
|
|
1340
1969
|
try {
|
|
1341
|
-
|
|
1970
|
+
const deserialized = deserialize(found);
|
|
1971
|
+
if (!validate(deserialized))
|
|
1972
|
+
return null;
|
|
1973
|
+
return deserialized;
|
|
1342
1974
|
}
|
|
1343
1975
|
catch (err) {
|
|
1344
1976
|
if (isDevMode())
|
|
@@ -1457,10 +2089,10 @@ class MessageBus {
|
|
|
1457
2089
|
this.channel.removeEventListener('message', listener);
|
|
1458
2090
|
this.listeners.delete(id);
|
|
1459
2091
|
}
|
|
1460
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.
|
|
1461
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.
|
|
2092
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2093
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
1462
2094
|
}
|
|
1463
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.
|
|
2095
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, decorators: [{
|
|
1464
2096
|
type: Injectable,
|
|
1465
2097
|
args: [{
|
|
1466
2098
|
providedIn: 'root',
|
|
@@ -1743,5 +2375,5 @@ function withHistory(source, opt) {
|
|
|
1743
2375
|
* Generated bundle index. Do not edit.
|
|
1744
2376
|
*/
|
|
1745
2377
|
|
|
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 };
|
|
2378
|
+
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
2379
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|