@mmstack/primitives 21.0.16 → 21.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -15
- package/fesm2022/mmstack-primitives.mjs +0 -1
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,15 +20,27 @@ This library provides the following primitives:
|
|
|
20
20
|
- `mutable` - A signal variant allowing in-place mutations while triggering updates.
|
|
21
21
|
- `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
|
|
22
22
|
- `piped` – Creates a signal with a chainable & typesafe `.pipe(...)` method, which returns a pipable computed.
|
|
23
|
+
- `store` - A deep-reactivity proxy, with Array & Record support.
|
|
23
24
|
- `withHistory` - Enhances a signal with a complete undo/redo history stack.
|
|
24
|
-
- `
|
|
25
|
+
- `indexArray` - Maps a reactive array by index into an array of stable derivations.
|
|
26
|
+
- `keyArray` - Maps a reactive array by key (track by) into an array of stable derivations.
|
|
27
|
+
- `mapObject` - Maps a reactive object by key (track by) into an object of stable derivations.
|
|
25
28
|
- `nestedEffect` - Creates an effect with a hierarchical lifetime, enabling fine-grained, conditional side-effects.
|
|
26
29
|
- `toWritable` - Converts a read-only signal to writable using custom write logic.
|
|
27
30
|
- `derived` - Creates a signal with two-way binding to a source signal.
|
|
31
|
+
- `chunked` - Creates a signal that time-slices an array into chunked values & emits thats array based on the provided options.
|
|
32
|
+
- `tabSync` - Low level primitive to "share" the value of a WritableSignal accross tabs via the BroadcastChannel api.
|
|
28
33
|
- `sensor` - A facade function to create various reactive sensor signals (e.g., mouse position, network status, page visibility, dark mode preference)." (This was the suggestion from before; it just reads a little smoother and more accurately reflects what the facade creates directly).
|
|
34
|
+
- `mediaQuery` - A generic primitive that tracks a CSS media query (forms the basis for `prefersDarkMode` and `prefersReducedMotion`).
|
|
35
|
+
- `elementVisibility` - Tracks if an element is intersecting the viewport using IntersectionObserver.
|
|
36
|
+
- `elementSize` - Tracks the size of the DOM element
|
|
37
|
+
- `mediaQuery` - Creates a signal that reacts to changes based on the provided media queries "truthyness". Additional helpers such as `prefersDarkMode` and `prefersReducedMotion` available
|
|
38
|
+
- `mousePosition` - Throttled signal that reacts to the mouses position within a given element
|
|
39
|
+
- `networkStatus` - A signal of the current network status, used my @mmstack/resource
|
|
40
|
+
- `pageVisibility` - A signal useful when reacting to the user switching tabs
|
|
41
|
+
- `scrollPosition` - A throttled signal of the current scroll position within a given element
|
|
42
|
+
- `windowSize` - A throttled signal useful to reacting to window resize events
|
|
29
43
|
- `until` - Creates a Promise that resolves when a signal's value meets a specific condition.
|
|
30
|
-
- `mediaQuery` - A generic primitive that tracks a CSS media query (forms the basis for `prefersDarkMode` and `prefersReducedMotion`).
|
|
31
|
-
- `elementVisibility` - Tracks if an element is intersecting the viewport using IntersectionObserver.
|
|
32
44
|
|
|
33
45
|
---
|
|
34
46
|
|
|
@@ -241,13 +253,122 @@ label(); // e.g., "#2"
|
|
|
241
253
|
total(); // reactive sum
|
|
242
254
|
```
|
|
243
255
|
|
|
244
|
-
###
|
|
256
|
+
### store / mutableStore / toStore
|
|
257
|
+
|
|
258
|
+
Provides "Deep Reactivity" by creating a proxy around a source object or array. Instead of reading raw values, accessing a property on a store returns a Signal representing that specific property.
|
|
259
|
+
This allows you to pass specific slices of a large state object to child components as Input() signals, or bind directly to nested properties without manually creating computed or derived selectors.
|
|
260
|
+
Propagates mutablity/writability down the chain so if the source is a WritableSignal all children are WritableSignal derivations.
|
|
261
|
+
|
|
262
|
+
#### Features:
|
|
263
|
+
|
|
264
|
+
- Lazy Generation: Sub-signals are created only when accessed.
|
|
265
|
+
- Caching: Accessed signals are cached (via WeakRef), so accessing state.user.name multiple times returns the exact same signal instance.
|
|
266
|
+
- Array Support: Array signals provide reactive access to indices (e.g., state.users[0])
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { Component, effect } from '@angular/core';
|
|
270
|
+
import { store, mutableStore } from '@mmstack/primitives';
|
|
271
|
+
import { FormsModule } from '@angular/forms';
|
|
272
|
+
import { JsonPipe } from '@angular/common';
|
|
273
|
+
|
|
274
|
+
@Component({
|
|
275
|
+
selector: 'app-store-demo',
|
|
276
|
+
standalone: true,
|
|
277
|
+
imports: [FormsModule, JsonPipe],
|
|
278
|
+
template: `
|
|
279
|
+
<h3>User Profile</h3>
|
|
280
|
+
<p>Name: {{ state.user.name() }}</p>
|
|
281
|
+
|
|
282
|
+
<input [ngModel]="state.user.name()" (ngModelChange)="state.user.name.set($event)" />
|
|
283
|
+
|
|
284
|
+
<h3>Settings (Mutable)</h3>
|
|
285
|
+
<label>
|
|
286
|
+
<input type="checkbox" [checked]="settings.notifications.email()" (change)="toggleEmail()" />
|
|
287
|
+
Email Notifications
|
|
288
|
+
</label>
|
|
289
|
+
|
|
290
|
+
<pre>{{ state() | json }}</pre>
|
|
291
|
+
`,
|
|
292
|
+
})
|
|
293
|
+
export class StoreDemoComponent {
|
|
294
|
+
// 1. Standard Store
|
|
295
|
+
state = store({
|
|
296
|
+
user: {
|
|
297
|
+
name: 'Alice',
|
|
298
|
+
address: { city: 'New York', zip: 10001 },
|
|
299
|
+
},
|
|
300
|
+
tags: ['admin', 'editor'],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// 2. Mutable Store (allows .mutate/.inline)
|
|
304
|
+
settings = mutableStore({
|
|
305
|
+
theme: 'dark',
|
|
306
|
+
notifications: { email: true, sms: false },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
constructor() {
|
|
310
|
+
// Effect tracks only the specific slice accessed
|
|
311
|
+
effect(() => {
|
|
312
|
+
console.log('City changed to:', this.state.user.address.city());
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Array access returns a signal for that index
|
|
316
|
+
const firstTag = this.state.tags[0];
|
|
317
|
+
console.log('First tag:', firstTag()); // 'admin'
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
updateZip() {
|
|
321
|
+
// You can set deep properties directly
|
|
322
|
+
this.state.user.address.zip.set(90210);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
toggleEmail() {
|
|
326
|
+
// With mutableStore, you can use .mutate on the root or sub-signals
|
|
327
|
+
this.settings.notifications.mutate((n) => {
|
|
328
|
+
n.email = !n.email;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Array Stores
|
|
335
|
+
|
|
336
|
+
When a store holds an array, the array itself is a signal, but you can also access indices as signals. Additionally Array stores also expose a `.length` signal & support Symbol.Iterator.
|
|
337
|
+
Currently the array store function isn't exposed, but they are automatically created when a given property within the store is an array. Hit me up, if you need top-level array support, though in those cases you're probably looking for `indexArray` / `keyArray`
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const state = store({
|
|
341
|
+
todos: [
|
|
342
|
+
{ id: 1, text: 'Buy Milk', done: false },
|
|
343
|
+
{ id: 2, text: 'Walk Dog', done: true },
|
|
344
|
+
],
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const firstTodo = state.todos[0]; // Signal<{ text: string, ... }>
|
|
348
|
+
const firstTodoText = state.todos[0].text; // Signal<string>
|
|
349
|
+
|
|
350
|
+
// Update specific item property without replacing the whole array
|
|
351
|
+
state.todos[0].done.set(true);
|
|
352
|
+
|
|
353
|
+
const len = state.todos.length(); // reacts to length changes
|
|
354
|
+
|
|
355
|
+
for (const todo of state.todos) {
|
|
356
|
+
const t = todo(); // iteration returns proxied children
|
|
357
|
+
const id = todo.id();
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### indexArray/keyArray
|
|
245
362
|
|
|
246
363
|
Reactive map helper that stabilizes a source array Signal by length. It provides stability by giving the mapping function a stable Signal<T> for each item based on its index. Sub signals are not re-created, rather they propagate value updates through. This is particularly useful for rendering lists (@for) as it minimizes DOM changes when array items change identity but represent the same conceptual entity.
|
|
247
364
|
|
|
365
|
+
`keyArray` is similar, but stabilizes/reconciles via a provided track by function instead of the index. This is computationally more expensive, so use it when "identity" stability is more important than simply data pass-through
|
|
366
|
+
|
|
367
|
+
Both utilize memory pooling to "ease" GC pressure.
|
|
368
|
+
|
|
248
369
|
```typescript
|
|
249
370
|
import { Component, signal } from '@angular/core';
|
|
250
|
-
import {
|
|
371
|
+
import { indexArray, keyArray, mutable } from '@mmstack/primitives';
|
|
251
372
|
|
|
252
373
|
@Component({
|
|
253
374
|
selector: 'app-map-demo',
|
|
@@ -269,7 +390,13 @@ export class ListComponent {
|
|
|
269
390
|
{ id: 2, name: 'B' },
|
|
270
391
|
]);
|
|
271
392
|
|
|
272
|
-
readonly displayItems =
|
|
393
|
+
readonly displayItems = indexArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
|
|
394
|
+
|
|
395
|
+
// keyArray is similar, but the index becomes dynamic & the child object is static
|
|
396
|
+
readonly keyed = keyArray(this.sourceItems, (child, index) => computed(() => `Item ${index()}: ${child.name}}`), {
|
|
397
|
+
key: (item) => item.id
|
|
398
|
+
});
|
|
399
|
+
|
|
273
400
|
|
|
274
401
|
addItem() {
|
|
275
402
|
this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
|
|
@@ -278,12 +405,12 @@ export class ListComponent {
|
|
|
278
405
|
updateFirst() {
|
|
279
406
|
this.sourceItems.update((items) => {
|
|
280
407
|
items[0] = { ...items[0], name: items[0].name + '+' };
|
|
281
|
-
return [...items]; // New array, but
|
|
408
|
+
return [...items]; // New array, but indexArray keeps stable signals
|
|
282
409
|
});
|
|
283
410
|
}
|
|
284
411
|
|
|
285
412
|
// since the underlying source is a signal we can also create updaters in the mapper
|
|
286
|
-
readonly updatableItems =
|
|
413
|
+
readonly updatableItems = indexArray(this.sourceItems, (child, index) => {
|
|
287
414
|
|
|
288
415
|
return {
|
|
289
416
|
value: computed(() => `Item ${index}: ${child().name}`))
|
|
@@ -293,7 +420,7 @@ export class ListComponent {
|
|
|
293
420
|
|
|
294
421
|
|
|
295
422
|
// since the underlying source is a WritableSignal we can also create updaters in the mapper
|
|
296
|
-
readonly writableItems =
|
|
423
|
+
readonly writableItems = indexArray(this.sourceItems, (child, index) => {
|
|
297
424
|
|
|
298
425
|
return {
|
|
299
426
|
value: computed(() => `Item ${index}: ${child().name}`))
|
|
@@ -307,7 +434,7 @@ export class ListComponent {
|
|
|
307
434
|
{ id: 2, name: 'B' },
|
|
308
435
|
]);
|
|
309
436
|
|
|
310
|
-
readonly mutableItems =
|
|
437
|
+
readonly mutableItems = indexArray(this.sourceItems, (child, index) => {
|
|
311
438
|
|
|
312
439
|
return {
|
|
313
440
|
value: computed(() => `Item ${index}: ${child().name}`))
|
|
@@ -319,6 +446,62 @@ export class ListComponent {
|
|
|
319
446
|
}
|
|
320
447
|
```
|
|
321
448
|
|
|
449
|
+
### mapObject
|
|
450
|
+
|
|
451
|
+
Projects a reactive object (Record<string, T>) into a new object (Record<string, U>), maintaining referential stability for values associated with unchanged keys. This is the Object-equivalent of keyArray.
|
|
452
|
+
|
|
453
|
+
The projection function receives the key and a value Signal. If the source is a WritableSignal or MutableSignal, the provided value signal is also writable (via derived), allowing child components or logic to update the specific property in the source object directly.
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
import { Component, signal, computed } from '@angular/core';
|
|
457
|
+
import { mapObject } from '@mmstack/primitives';
|
|
458
|
+
|
|
459
|
+
@Component({
|
|
460
|
+
selector: 'app-settings',
|
|
461
|
+
template: `
|
|
462
|
+
@for (key of objectKeys(controls()); track key) {
|
|
463
|
+
<div class="setting">
|
|
464
|
+
<span>{{ controls()[key].label }}</span>
|
|
465
|
+
<button (click)="controls()[key].toggle()">
|
|
466
|
+
{{ controls()[key].isActive() ? 'ON' : 'OFF' }}
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
469
|
+
}
|
|
470
|
+
`,
|
|
471
|
+
})
|
|
472
|
+
export class SettingsComponent {
|
|
473
|
+
objectKeys = Object.keys;
|
|
474
|
+
|
|
475
|
+
// Source state
|
|
476
|
+
readonly settings = signal<Record<string, boolean>>({
|
|
477
|
+
wifi: true,
|
|
478
|
+
bluetooth: false,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Mapped object: { [key]: { label, isActive, toggle } }
|
|
482
|
+
readonly controls = mapObject(
|
|
483
|
+
this.settings,
|
|
484
|
+
(key, value) => {
|
|
485
|
+
// 'value' is a WritableSignal linked to this specific property
|
|
486
|
+
return {
|
|
487
|
+
label: key.toUpperCase(),
|
|
488
|
+
isActive: value, // Expose as ReadOnly for template
|
|
489
|
+
toggle: () => value.update((v) => !v),
|
|
490
|
+
destroy: () => console.log(`Cleanup logic for ${key}`),
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
// Optional cleanup hook when a key is removed from the source
|
|
495
|
+
onDestroy: (mappedItem) => mappedItem.destroy(),
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
addSetting() {
|
|
500
|
+
this.settings.update((s) => ({ ...s, airdrop: false }));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
322
505
|
### nestedEffect
|
|
323
506
|
|
|
324
507
|
Creates an effect that can be nested, similar to SolidJS's `createEffect`.
|
|
@@ -366,11 +549,11 @@ export class NestedDemoComponent {
|
|
|
366
549
|
|
|
367
550
|
#### Advanced Example: Fine-grained Lists
|
|
368
551
|
|
|
369
|
-
`nestedEffect` can be composed with `
|
|
552
|
+
`nestedEffect` can be composed with `indexArray` to create truly fine-grained reactive lists, where each item can manage its own side-effects (like external library integrations) that are automatically cleaned up when the item is removed.
|
|
370
553
|
|
|
371
554
|
```ts
|
|
372
555
|
import { Component, signal, computed } from '@angular/core';
|
|
373
|
-
import {
|
|
556
|
+
import { indexArray, nestedEffect } from '@mmstack/primitives';
|
|
374
557
|
|
|
375
558
|
@Component({ selector: 'app-list-demo' })
|
|
376
559
|
export class ListDemoComponent {
|
|
@@ -379,8 +562,8 @@ export class ListDemoComponent {
|
|
|
379
562
|
{ id: 2, name: 'Bob' },
|
|
380
563
|
]);
|
|
381
564
|
|
|
382
|
-
//
|
|
383
|
-
readonly mappedUsers =
|
|
565
|
+
// indexArray creates stable signals for each item
|
|
566
|
+
readonly mappedUsers = indexArray(
|
|
384
567
|
this.users,
|
|
385
568
|
(userSignal, index) => {
|
|
386
569
|
// Create a side-effect tied to THIS item's lifetime
|
|
@@ -399,7 +582,7 @@ export class ListDemoComponent {
|
|
|
399
582
|
};
|
|
400
583
|
},
|
|
401
584
|
{
|
|
402
|
-
// When
|
|
585
|
+
// When indexArray removes an item, it calls `onDestroy`
|
|
403
586
|
onDestroy: (mappedItem) => {
|
|
404
587
|
mappedItem._destroy(); // Manually destroy the nested effect
|
|
405
588
|
},
|
|
@@ -440,6 +623,84 @@ const name2 = derived(user, {
|
|
|
440
623
|
});
|
|
441
624
|
```
|
|
442
625
|
|
|
626
|
+
### chunked
|
|
627
|
+
|
|
628
|
+
Creates a Signal that progressively emits segments of a source array, chunk by chunk. This is a time-slicing primitive designed to keep the main thread responsive when rendering large lists or processing heavy data sets.
|
|
629
|
+
|
|
630
|
+
Instead of rendering 10,000 items at once (which would freeze the UI), chunked emits the first batch immediately, then schedules the next batch to be added in the next frame (or after a delay), repeating until the full list is visible. If the source array changes, the process resets and starts over from the first chunk.
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
import { Component, signal } from '@angular/core';
|
|
634
|
+
import { chunked } from '@mmstack/primitives';
|
|
635
|
+
|
|
636
|
+
@Component({
|
|
637
|
+
selector: 'app-heavy-list',
|
|
638
|
+
template: `
|
|
639
|
+
<div class="status-bar">Loaded: {{ visibleItems().length }} / {{ allItems().length }}</div>
|
|
640
|
+
|
|
641
|
+
<ul>
|
|
642
|
+
@for (item of visibleItems(); track item.id) {
|
|
643
|
+
<li>{{ item.label }}</li>
|
|
644
|
+
}
|
|
645
|
+
</ul>
|
|
646
|
+
`,
|
|
647
|
+
})
|
|
648
|
+
export class HeavyListComponent {
|
|
649
|
+
// A heavy source with 10,000 items
|
|
650
|
+
readonly allItems = signal(Array.from({ length: 10000 }, (_, i) => ({ id: i, label: `Item #${i}` })));
|
|
651
|
+
|
|
652
|
+
// Process 100 items per animation frame to prevent UI blocking
|
|
653
|
+
readonly visibleItems = chunked(this.allItems, {
|
|
654
|
+
chunkSize: 100,
|
|
655
|
+
delay: 'frame', // 'frame' | 'microtask' | number (ms)
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
### tabSync
|
|
661
|
+
|
|
662
|
+
A low-level primitive that synchronizes a WritableSignal across multiple browser tabs or windows of the same application using the BroadcastChannel API. Used by the cache in @mmstack/resource & the stored signal.
|
|
663
|
+
|
|
664
|
+
When the signal is updated in one tab, the new value is broadcast and automatically set in the corresponding signal in all other open tabs. This is ideal for synchronizing global state like user sessions, theme preferences, or shopping cart data.
|
|
665
|
+
|
|
666
|
+
#### Key Features:
|
|
667
|
+
|
|
668
|
+
- SSR Safe: Gracefully degrades to a standard signal on the server.
|
|
669
|
+
- Automatic Cleanup: Handles event listeners and disconnects when the injection context is destroyed.
|
|
670
|
+
- Smart ID Generation: Can auto-generate IDs for rapid prototyping, but supports explicit IDs for production stability.
|
|
671
|
+
|
|
672
|
+
Note: While tabSync attempts to generate a deterministic ID based on the call site, it is highly recommended to provide a manual id string in production to ensure stability across different builds and minification processes.
|
|
673
|
+
|
|
674
|
+
```ts
|
|
675
|
+
import { Component, signal } from '@angular/core';
|
|
676
|
+
import { tabSync } from '@mmstack/primitives';
|
|
677
|
+
|
|
678
|
+
@Component({
|
|
679
|
+
selector: 'app-sync-demo',
|
|
680
|
+
template: `
|
|
681
|
+
<p>Open this page in two tabs!</p>
|
|
682
|
+
|
|
683
|
+
<button (click)="counter.update(n => n + 1)">Count: {{ counter() }}</button>
|
|
684
|
+
|
|
685
|
+
<select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
|
|
686
|
+
<option value="light">Light</option>
|
|
687
|
+
<option value="dark">Dark</option>
|
|
688
|
+
</select>
|
|
689
|
+
`,
|
|
690
|
+
})
|
|
691
|
+
export class SyncDemoComponent {
|
|
692
|
+
// 1. Quick usage (Auto-ID)
|
|
693
|
+
// Good for dev, but ID might change if code moves lines/files
|
|
694
|
+
readonly counter = tabSync(signal(0));
|
|
695
|
+
|
|
696
|
+
// 2. Production usage (Explicit ID)
|
|
697
|
+
// Recommended: Ensures tabs always find each other regardless of minification
|
|
698
|
+
readonly theme = tabSync(signal('light'), {
|
|
699
|
+
id: 'global-app-theme',
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
443
704
|
### withHistory
|
|
444
705
|
|
|
445
706
|
Enhances any WritableSignal with a complete undo/redo history stack. This is useful for building user-friendly editors, forms, or any feature where reverting changes is necessary. It provides .undo(), .redo(), and .clear() methods, along with reactive boolean signals like .canUndo and .canRedo to easily enable or disable UI controls.
|
|
@@ -1770,7 +1770,6 @@ function toArrayStore(source, injector) {
|
|
|
1770
1770
|
});
|
|
1771
1771
|
}
|
|
1772
1772
|
/**
|
|
1773
|
-
* @experimental This API is experimental and may change or be removed in future releases.
|
|
1774
1773
|
* Converts a Signal into a deep-observable Store.
|
|
1775
1774
|
* Accessing nested properties returns a derived Signal of that path.
|
|
1776
1775
|
* @example
|