@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/README.md CHANGED
@@ -1,777 +1,1038 @@
1
- # @mmstack/primitives
2
-
3
- A collection of utility functions and primitives designed to enhance development with Angular Signals, providing helpful patterns and inspired by features from other reactive libraries. All value helpers also use pure derivations (no effects/RxJS).
4
-
5
- [![npm version](https://badge.fury.io/js/%40mmstack%2Fprimitives.svg)](https://badge.fury.io/js/%40mmstack%2Fprimitives)
6
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/packages/primitives/LICENSE)
7
-
8
- ## Installation
9
-
10
- ```bash
11
- npm install @mmstack/primitives
12
- ```
13
-
14
- ## Primitives
15
-
16
- This library provides the following primitives:
17
-
18
- - `debounced` - Creates a writable signal whose value updates are debounced after set/update.
19
- - `throttled` - Creates a writable signal whose value updates are rate-limited.
20
- - `mutable` - A signal variant allowing in-place mutations while triggering updates.
21
- - `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
22
- - `piped` – Creates a signal with a chainable & typesafe `.pipe(...)` method, which returns a pipable computed.
23
- - `withHistory` - Enhances a signal with a complete undo/redo history stack.
24
- - `mapArray` - Maps a reactive array efficently into an array of stable derivations.
25
- - `nestedEffect` - Creates an effect with a hierarchical lifetime, enabling fine-grained, conditional side-effects.
26
- - `toWritable` - Converts a read-only signal to writable using custom write logic.
27
- - `derived` - Creates a signal with two-way binding to a source signal.
28
- - `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).
29
- - `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
-
33
- ---
34
-
35
- ### debounced
36
-
37
- Creates a WritableSignal where the propagation of its value (after calls to .set() or .update()) is delayed. The publicly readable signal value updates only after a specified time (ms) has passed without further set/update calls. It also includes an .original property, which is a Signal reflecting the value immediately after set/update is called.
38
-
39
- ```typescript
40
- import { Component, signal, effect } from '@angular/core';
41
- import { debounced, debounce } from '@mmstack/primitives';
42
- import { FormsModule } from '@angular/forms';
43
-
44
- @Component({
45
- selector: 'app-debounced',
46
- template: `<input [(ngModel)]="searchTerm" />`,
47
- })
48
- export class SearchComponent {
49
- searchTerm = debounced('', { ms: 300 }); // Debounce for 300ms
50
-
51
- constructor() {
52
- effect(() => {
53
- // Runs 300ms after the user stops typing
54
- console.log('Perform search for:', this.searchTerm());
55
- });
56
- effect(() => {
57
- // Runs immediately on input change
58
- console.log('Input value:', this.searchTerm.original());
59
- });
60
- }
61
- }
62
- ```
63
-
64
- You can also debounce an existing signal:
65
-
66
- ```typescript
67
- import { debounce } from '@mmstack/primitives';
68
-
69
- const query = signal('');
70
- const debouncedQuery = debounce(query, { ms: 300 });
71
- ```
72
-
73
- ### throttled
74
-
75
- Creates a WritableSignal whose value is rate-limited. It ensures that the public-facing signal only updates at most once per specified time interval (ms). It uses a trailing-edge strategy, meaning it updates with the most recent value at the end of the interval. This is useful for handling high-frequency events like scrolling or mouse movement without overwhelming your application's reactivity.
76
-
77
- ```typescript
78
- import { Component, signal, effect } from '@angular/core';
79
- import { throttled } from '@mmstack/primitives';
80
- import { JsonPipe } from '@angular/common';
81
-
82
- @Component({
83
- selector: 'app-throttle-demo',
84
- standalone: true,
85
- imports: [JsonPipe],
86
- template: `
87
- <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here to see updates...</div>
88
- <p><b>Original Position:</b> {{ position.original() | json }}</p>
89
- <p><b>Throttled Position:</b> {{ position() | json }}</p>
90
- `,
91
- })
92
- export class ThrottleDemoComponent {
93
- // Throttle updates to at most once every 200ms
94
- position = throttled({ x: 0, y: 0 }, { ms: 200 });
95
-
96
- constructor() {
97
- // This effect runs on every single mouse move event.
98
- effect(() => {
99
- // console.log('Original value updated:', this.position.original());
100
- });
101
- // This effect will only run at most every 200ms.
102
- effect(() => {
103
- console.log('Throttled value updated:', this.position());
104
- });
105
- }
106
-
107
- onMouseMove(event: MouseEvent) {
108
- this.position.set({ x: event.offsetX, y: event.offsetY });
109
- }
110
- }
111
- ```
112
-
113
- ### mutable
114
-
115
- Creates a MutableSignal, a signal variant designed for scenarios where you want to perform in-place mutations on objects or arrays held within the signal, while still ensuring Angular's change detection is correctly triggered. It provides .mutate() and .inline() methods alongside the standard .set() and .update(). Please note that any computeds, which resolve non-primitive values from a mutable require equals to be set to false.
116
-
117
- ```typescript
118
- import { Component, computed, effect } from '@angular/core';
119
- import { mutable } from '@mmstack/primitives';
120
- import { FormsModule } from '@angular/forms';
121
-
122
- @Component({
123
- selector: 'app-mutable',
124
- template: ` <button (click)="incrementAge()">inc</button> `,
125
- })
126
- export class SearchComponent {
127
- user = mutable({ name: { first: 'John', last: 'Doe' }, age: 30 });
128
-
129
- constructor() {
130
- effect(() => {
131
- // Runs every time user is mutated
132
- console.log(this.user());
133
- });
134
-
135
- const age = computed(() => this.user().age);
136
-
137
- effect(() => {
138
- // Runs every time age changes
139
- console.log(age());
140
- });
141
-
142
- const name = computed(() => this.user().name);
143
- effect(() => {
144
- // Doesnt run if user changes, unless name is destructured
145
- console.log(name());
146
- });
147
-
148
- const name2 = computed(() => this.user().name, {
149
- equal: () => false,
150
- });
151
-
152
- effect(() => {
153
- // Runs every time user changes (even if name did not change)
154
- console.log(name2());
155
- });
156
- }
157
-
158
- incrementAge() {
159
- user.mutate((prev) => {
160
- prev.age++;
161
- return prev;
162
- });
163
- }
164
-
165
- incrementInline() {
166
- user.inline((prev) => {
167
- prev.age++;
168
- });
169
- }
170
- }
171
- ```
172
-
173
- ### stored
174
-
175
- Creates a WritableSignal whose state is automatically synchronized with persistent storage (like localStorage or sessionStorage), providing a fallback value when no data is found or fails to parse.
176
-
177
- It handles Server-Side Rendering (SSR) gracefully, allows dynamic storage keys, custom serialization/deserialization, custom storage providers, and optional synchronization across browser tabs via the storage event. It returns a StoredSignal<T> which includes a .clear() method and a reactive .key signal.
178
-
179
- ```typescript
180
- import { Component, effect, signal } from '@angular/core';
181
- import { stored } from '@mmstack/primitives';
182
- // import { FormsModule } from '@angular/forms'; // Needed for ngModel
183
-
184
- @Component({
185
- selector: 'app-theme-selector',
186
- standalone: true,
187
- // imports: [FormsModule], // Import if using ngModel
188
- template: `
189
- Theme:
190
- <select [value]="theme()" (change)="theme.set($event.target.value)">
191
- <option value="light">Light</option>
192
- <option value="dark">Dark</option>
193
- <option value="system">System</option>
194
- </select>
195
- <button (click)="theme.clear()">Reset Theme</button>
196
- <p>Using storage key: {{ theme.key() }}</p>
197
- `,
198
- })
199
- export class ThemeSelectorComponent {
200
- // Persist theme preference in localStorage, default to 'system'
201
- theme = stored<'light' | 'dark' | 'system'>('system', {
202
- key: 'user-theme',
203
- syncTabs: true, // Sync theme choice across tabs
204
- });
205
-
206
- constructor() {
207
- effect(() => {
208
- console.log(`Theme set to: ${this.theme()}`);
209
- // Logic to apply theme (e.g., add class to body)
210
- document.body.className = `theme-${this.theme()}`;
211
- });
212
- }
213
- }
214
- ```
215
-
216
- ### piped
217
-
218
- Adds two fluent APIs to signals:
219
-
220
- - **`.map(...transforms, [options])`** compose pure, synchronous value→value transforms. Returns a computed signal that remains pipeable.
221
- - **`.pipe(...operators)`** compose operators (signal→signal), useful for combining signals or reusable projections.
222
-
223
- ```typescript
224
- import { piped, pipeable, select, combineWith } from '@mmstack/primitives';
225
- import { signal } from '@angular/core';
226
-
227
- const count = piped(1);
228
-
229
- // Map: value -> value
230
- const label = count.map(
231
- (n) => n * 2,
232
- (n) => (num: n),
233
- { equal: (a, b) => a.num === b.num },
234
- );
235
-
236
- // Pipe: signal -> signal
237
- const base = pipeable(signal(10));
238
- const total = count.pipe(select((n) => n * 3)).pipe(combineWith(count, (a, b) => a + b));
239
-
240
- label(); // e.g., "#2"
241
- total(); // reactive sum
242
- ```
243
-
244
- ### mapArray
245
-
246
- 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
-
248
- ```typescript
249
- import { Component, signal } from '@angular/core';
250
- import { mapArray, mutable } from '@mmstack/primitives';
251
-
252
- @Component({
253
- selector: 'app-map-demo',
254
- template: `
255
- <ul>
256
- @for (item of displayItems(); track item) {
257
- <li>{{ item() }}</li>
258
- @if ($first) {
259
- <button (click)="updateFirst(item)">Update First</button>
260
- }
261
- }
262
- </ul>
263
- <button (click)="addItem()">Add</button>
264
- `,
265
- })
266
- export class ListComponent {
267
- readonly sourceItems = signal([
268
- { id: 1, name: 'A' },
269
- { id: 2, name: 'B' },
270
- ]);
271
-
272
- readonly displayItems = mapArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
273
-
274
- addItem() {
275
- this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
276
- }
277
-
278
- updateFirst() {
279
- this.sourceItems.update((items) => {
280
- items[0] = { ...items[0], name: items[0].name + '+' };
281
- return [...items]; // New array, but mapArray keeps stable signals
282
- });
283
- }
284
-
285
- // since the underlying source is a signal we can also create updaters in the mapper
286
- readonly updatableItems = mapArray(this.sourceItems, (child, index) => {
287
-
288
- return {
289
- value: computed(() => `Item ${index}: ${child().name}`))
290
- updateName: () => child.update((cur) => ({...cur, name: cur.name + '+'}))
291
- };
292
- });
293
-
294
-
295
- // since the underlying source is a WritableSignal we can also create updaters in the mapper
296
- readonly writableItems = mapArray(this.sourceItems, (child, index) => {
297
-
298
- return {
299
- value: computed(() => `Item ${index}: ${child().name}`))
300
- updateName: () => child.update((cur) => ({...cur, name: cur.name + '+'}))
301
- };
302
- });
303
-
304
- // if the source is a mutable signal we can even update them inline
305
- readonly sourceItems = mutable([
306
- { id: 1, name: 'A' },
307
- { id: 2, name: 'B' },
308
- ]);
309
-
310
- readonly mutableItems = mapArray(this.sourceItems, (child, index) => {
311
-
312
- return {
313
- value: computed(() => `Item ${index}: ${child().name}`))
314
- updateName: () => child.inline((cur) => {
315
- cur.name += '+';
316
- })
317
- };
318
- });
319
- }
320
- ```
321
-
322
- ### nestedEffect
323
-
324
- Creates an effect that can be nested, similar to SolidJS's `createEffect`.
325
-
326
- This primitive enables true hierarchical reactivity. A `nestedEffect` created within another `nestedEffect` is **automatically destroyed and recreated** when the parent re-runs.
327
-
328
- It automatically handles injector propagation and lifetime management, allowing you to create fine-grained, conditional side-effects that only track dependencies when they are "live". This is a powerful optimization for scenarios where a "hot" signal (which changes often) should only be tracked when a "cold" signal (a condition that changes rarely) is true.
329
-
330
- ```ts
331
- import { Component, signal } from '@angular/core';
332
- import { nestedEffect } from '@mmstack/primitives';
333
-
334
- @Component({ selector: 'app-nested-demo' })
335
- export class NestedDemoComponent {
336
- // `coldGuard` changes rarely
337
- readonly coldGuard = signal(false);
338
- // `hotSignal` changes very often
339
- readonly hotSignal = signal(0);
340
-
341
- constructor() {
342
- // A standard effect would track *both* signals and run
343
- // every time `hotSignal` changes, even if `coldGuard` is false.
344
- // effect(() => {
345
- // if (this.coldGuard()) {
346
- // console.log('Hot signal is:', this.hotSignal());
347
- // }
348
- // });
349
-
350
- // `nestedEffect` solves this:
351
- nestedEffect(() => {
352
- // This outer effect ONLY tracks `coldGuard`.
353
- // It does not track `hotSignal`.
354
- if (this.coldGuard()) {
355
- // This inner effect is CREATED when coldGuard is true
356
- // and DESTROYED when it becomes false.
357
- nestedEffect(() => {
358
- // It only tracks `hotSignal` while it exists.
359
- console.log('Hot signal is:', this.hotSignal());
360
- });
361
- }
362
- });
363
- }
364
- }
365
- ```
366
-
367
- #### Advanced Example: Fine-grained Lists
368
-
369
- `nestedEffect` can be composed with `mapArray` 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
-
371
- ```ts
372
- import { Component, signal, computed } from '@angular/core';
373
- import { mapArray, nestedEffect } from '@mmstack/primitives';
374
-
375
- @Component({ selector: 'app-list-demo' })
376
- export class ListDemoComponent {
377
- readonly users = signal([
378
- { id: 1, name: 'Alice' },
379
- { id: 2, name: 'Bob' },
380
- ]);
381
-
382
- // mapArray creates stable signals for each item
383
- readonly mappedUsers = mapArray(
384
- this.users,
385
- (userSignal, index) => {
386
- // Create a side-effect tied to THIS item's lifetime
387
- const effectRef = nestedEffect(() => {
388
- // This only runs if `userSignal` (this specific user) changes.
389
- console.log(`User ${index} updated:`, userSignal().name);
390
-
391
- // e.g., updateAGGridRow(index, userSignal());
392
- });
393
-
394
- // Return the data and the cleanup logic
395
- return {
396
- label: computed(() => `User: ${userSignal().name}`),
397
- // This function will be called by `onDestroy`
398
- _destroy: () => effectRef.destroy(),
399
- };
400
- },
401
- {
402
- // When mapArray removes an item, it calls `onDestroy`
403
- onDestroy: (mappedItem) => {
404
- mappedItem._destroy(); // Manually destroy the nested effect
405
- },
406
- },
407
- );
408
- }
409
- ```
410
-
411
- ### toWritable
412
-
413
- A utility function that converts a read-only Signal into a WritableSignal by allowing you to provide custom implementations for the .set() and .update() methods. This is useful for creating controlled write access to signals that are naturally read-only (like those created by computed). This is used under the hood in derived.
414
-
415
- ```typescript
416
- import { Component, signal, effect } from '@angular/core';
417
- import { toWritable } from '@mmstack/primitives';
418
-
419
- const user = signal({ name: 'John' });
420
-
421
- const name = toWritable(
422
- computed(() => user().name),
423
- (name) => user.update((prev) => ({ ...prev, name })),
424
- ); // WritableSignal<string> bound to user signal
425
- ```
426
-
427
- ### derived
428
-
429
- Creates a WritableSignal that represents a part of another source WritableSignal (e.g., an object property or an array element), enabling two-way data binding. Changes to the source update the derived signal, and changes to the derived signal (via .set() or .update()) update the source signal accordingly.
430
-
431
- ```typescript
432
- const user = signal({ name: 'John' });
433
-
434
- const name = derived(user, 'name'); // WritableSignal<string>, which updates user signal & reacts to changes in the name property
435
-
436
- // Full syntax example
437
- const name2 = derived(user, {
438
- from: (u) => u.name,
439
- onChange: (name) => user.update((prev) => ({ ...prev, name })),
440
- });
441
- ```
442
-
443
- ### withHistory
444
-
445
- 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.
446
-
447
- ```typescript
448
- import { FormsModule } from '@angular/forms';
449
- import { JsonPipe } from '@angular/common';
450
- import { withHistory } from '@mmstack/primitives';
451
- import { Component, signal, effect } from '@angular/core';
452
-
453
- @Component({
454
- selector: 'app-history-demo',
455
- standalone: true,
456
- imports: [FormsModule, JsonPipe],
457
- template: `
458
- <h4>Simple Text Editor</h4>
459
- <textarea [(ngModel)]="text" rows="4" cols="50"></textarea>
460
- <div class="buttons" style="margin-top: 8px; display: flex; gap: 8px;">
461
- <button (click)="text.undo()" [disabled]="!text.canUndo()">Undo</button>
462
- <button (click)="text.redo()" [disabled]="!text.canRedo()">Redo</button>
463
- <button (click)="text.clear()" [disabled]="!text.canClear()">Clear History</button>
464
- </div>
465
- <p>History Stack:</p>
466
- <pre>{{ text.history() | json }}</pre>
467
- `,
468
- })
469
- export class HistoryDemoComponent {
470
- // Create a signal and immediately enhance it with history capabilities.
471
- text = withHistory(signal('Hello, type something!'), { maxSize: 10 });
472
-
473
- constructor() {
474
- // You can react to history changes as well
475
- effect(() => {
476
- console.log('History stack changed:', this.text.history());
477
- });
478
- }
479
- }
480
- ```
481
-
482
- ### sensor
483
-
484
- ### sensor
485
-
486
- The `sensor()` facade provides a unified way to create various reactive sensor signals that track browser events, states, and user preferences. You specify the type of sensor you want (e.g., `'mousePosition'`, `'networkStatus'`, `'windowSize'`, `'dark-mode'`), and it returns the corresponding signal, often with specific properties or methods. These primitives are generally SSR-safe and handle their own event listener cleanup.
487
-
488
- You can either use the `sensor('sensorType', options)` facade or import the specific sensor functions directly if you prefer.
489
-
490
- **Facade Usage Example:**
491
-
492
- ```typescript
493
- import { sensor } from '@mmstack/primitives';
494
- import { effect } from '@angular/core';
495
-
496
- const network = sensor('networkStatus');
497
- const mouse = sensor('mousePosition', { throttle: 50, coordinateSpace: 'page' });
498
- const winSize = sensor('windowSize', { throttle: 150 });
499
- const isDark = sensor('dark-mode');
500
-
501
- effect(() => console.log('Online:', network().isOnline));
502
- effect(() => console.log('Mouse X:', mouse().x));
503
- effect(() => console.log('Window Width:', winSize().width));
504
- effect(() => console.log('Dark Mode Preferred:', isDark()));
505
- ```
506
-
507
- Individual sensors available through the facade or direct import:
508
-
509
- #### mousePosition
510
-
511
- Tracks the mouse cursor's position. By default, updates are throttled to 100ms. It provides the main throttled signal and an .unthrottled property to access the raw updates.
512
-
513
- Key Options: target, coordinateSpace ('client' or 'page'), touch (boolean), throttle (ms).
514
-
515
- ```typescript
516
- import { Component, effect } from '@angular/core';
517
- import { sensor } from '@mmstack/primitives'; // Or import { mousePosition }
518
- import { JsonPipe } from '@angular/common';
519
-
520
- @Component({
521
- selector: 'app-mouse-tracker',
522
- standalone: true,
523
- imports: [JsonPipe],
524
- template: `
525
- <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here...</div>
526
- <p><b>Throttled Position:</b> {{ mousePos() | json }}</p>
527
- <p><b>Unthrottled Position:</b> {{ mousePos.unthrottled() | json }}</p>
528
- `,
529
- })
530
- export class MouseTrackerComponent {
531
- // Using the facade
532
- readonly mousePos = sensor('mousePosition', { coordinateSpace: 'page', throttle: 200 });
533
- // Or direct import:
534
- // readonly mousePos = mousePosition({ coordinateSpace: 'page', throttle: 200 });
535
-
536
- // Note: The (mousemove) event here is just to show the example area works.
537
- // The mousePosition sensor binds its own listeners based on the target option.
538
- onMouseMove(event: MouseEvent) {
539
- // No need to call set, mousePosition handles it.
540
- }
541
-
542
- constructor() {
543
- effect(() => console.log('Throttled Mouse:', this.mousePos()));
544
- effect(() => console.log('Unthrottled Mouse:', this.mousePos.unthrottled()));
545
- }
546
- }
547
- ```
548
-
549
- #### networkStatus
550
-
551
- Tracks the browser's online/offline status. The returned signal is a boolean (`true` for online) and has an attached `.since` signal indicating when the status last changed.
552
-
553
- ```typescript
554
- import { Component, effect } from '@angular/core';
555
- import { sensor } from '@mmstack/primitives'; // Or import { networkStatus }
556
- import { DatePipe } from '@angular/common';
557
-
558
- @Component({
559
- selector: 'app-network-info',
560
- standalone: true,
561
- imports: [DatePipe],
562
- template: `
563
- @if (netStatus()) {
564
- <p>✅ Online (Since: {{ netStatus.since() | date: 'short' }})</p>
565
- } @else {
566
- <p>❌ Offline (Since: {{ netStatus.since() | date: 'short' }})</p>
567
- }
568
- `,
569
- })
570
- export class NetworkInfoComponent {
571
- readonly netStatus = sensor('networkStatus');
572
-
573
- constructor() {
574
- effect(() => {
575
- console.log('Network online:', this.netStatus(), 'Since:', this.netStatus.since());
576
- });
577
- }
578
- }
579
- ```
580
-
581
- #### pageVisibility
582
-
583
- Tracks the page's visibility state (e.g., 'visible', 'hidden') using the Page Visibility API.
584
-
585
- ```typescript
586
- import { Component, effect } from '@angular/core';
587
- import { sensor } from '@mmstack/primitives'; // Or import { pageVisibility }
588
-
589
- @Component({
590
- selector: 'app-visibility-logger',
591
- standalone: true,
592
- template: `<p>Page is currently: {{ visibility() }}</p>`,
593
- })
594
- export class VisibilityLoggerComponent {
595
- readonly visibility = sensor('pageVisibility');
596
-
597
- constructor() {
598
- effect(() => {
599
- console.log('Page visibility changed to:', this.visibility());
600
- if (this.visibility() === 'hidden') {
601
- // Perform cleanup or pause tasks
602
- }
603
- });
604
- }
605
- }
606
- ```
607
-
608
- #### windowSize
609
-
610
- Tracks the browser window's inner dimensions (width and height). Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
611
-
612
- ```typescript
613
- import { Component, effect, computed } from '@angular/core';
614
- import { sensor } from '@mmstack/primitives'; // Or import { windowSize }
615
-
616
- @Component({
617
- selector: 'app-responsive-display',
618
- standalone: true,
619
- template: `
620
- <p>Current Window Size: {{ winSize().width }}px x {{ winSize().height }}px</p>
621
- <p>Unthrottled: W: {{ winSize.unthrottled().width }} H: {{ winSize.unthrottled().height }}</p>
622
- @if (isMobileDisplay()) {
623
- <p>Displaying mobile layout.</p>
624
- } @else {
625
- <p>Displaying desktop layout.</p>
626
- }
627
- `,
628
- })
629
- export class ResponsiveDisplayComponent {
630
- readonly winSize = sensor('windowSize', { throttle: 150 });
631
- // Or: readonly winSize = windowSize({ throttle: 150 });
632
-
633
- readonly isMobileDisplay = computed(() => this.winSize().width < 768);
634
-
635
- constructor() {
636
- effect(() => console.log('Window Size (Throttled):', this.winSize()));
637
- }
638
- }
639
- ```
640
-
641
- #### scrollPosition
642
-
643
- Tracks the scroll position (x, y) of the window or a specified HTML element. Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
644
-
645
- ```typescript
646
- import { Component, effect, ElementRef, viewChild } from '@angular/core';
647
- import { sensor } from '@mmstack/primitives'; // Or import { scrollPosition }
648
- import { JsonPipe } from '@angular/common';
649
-
650
- @Component({
651
- selector: 'app-scroll-indicator',
652
- standalone: true,
653
- imports: [JsonPipe],
654
- template: `
655
- <div style="height: 100px; border-bottom: 2px solid red; position: fixed; top: 0; left: 0; width: 100%; background: white; z-index: 10;">
656
- Page Scroll Y: {{ pageScroll().y }}px
657
- <p>Unthrottled Y: {{ pageScroll.unthrottled().y }}</p>
658
- </div>
659
- <div #scrollableContent style="height: 2000px; padding-top: 120px;">Scroll down...</div>
660
- `,
661
- })
662
- export class ScrollIndicatorComponent {
663
- readonly pageScroll = sensor('scrollPosition', { throttle: 50 });
664
- // Or: readonly pageScroll = scrollPosition({ throttle: 50 });
665
-
666
- constructor() {
667
- effect(() => {
668
- // Example: Change header style based on scroll
669
- console.log('Page scroll Y (Throttled):', this.pageScroll().y);
670
- });
671
- }
672
- }
673
- ```
674
-
675
- #### mediaQuery, prefersDarkMode() & prefersReducedMotion()
676
-
677
- A generic mediaQuery primitive, you can use directly for any CSS media query. Two specific versions have been created for `prefersDarkMode()` & `prefersReducedMotion()`.
678
- Reacts to changes in preferences & exposes a `Signal<boolean>`.
679
-
680
- ```typescript
681
- import { Component, effect } from '@angular/core';
682
- import { mediaQuery, prefersDarkMode, prefersReducedMotion } from '@mmstack/primitives'; // Direct import
683
-
684
- @Component({
685
- selector: 'app-layout-checker',
686
- standalone: true,
687
- template: `
688
- @if (isLargeScreen()) {
689
- <p>Using large screen layout.</p>
690
- } @else {
691
- <p>Using small screen layout.</p>
692
- }
693
- `,
694
- })
695
- export class LayoutCheckerComponent {
696
- readonly isLargeScreen = mediaQuery('(min-width: 1280px)');
697
- readonly prefersDark = prefersDarkMode(); // is just a pre-defined mediaQuery signal
698
- readonly prefersReducedMotion = prefersReducedMotion(); // is just a pre-defined mediaQuery signal
699
- constructor() {
700
- effect(() => {
701
- console.log('Is large screen:', this.isLargeScreen());
702
- });
703
- }
704
- }
705
- ```
706
-
707
- ### until
708
-
709
- The `until` primitive provides a powerful way to bridge Angular's reactive signals with imperative, Promise-based asynchronous code. It returns a Promise that resolves when the value of a given signal satisfies a specified predicate function.
710
-
711
- This is particularly useful for:
712
-
713
- - Orchestrating complex sequences of operations (e.g., waiting for data to load or for a user action to complete before proceeding).
714
- - Writing tests where you need to await a certain state before making assertions.
715
- - Integrating with other Promise-based APIs.
716
-
717
- It also supports optional timeouts and automatic cancellation via DestroyRef if the consuming context (like a component) is destroyed before the condition is met.
718
-
719
- ```typescript
720
- import { signal } from '@angular/core';
721
- import { until } from '@mmstack/primitives';
722
-
723
- it('should reject on timeout if the condition is not met in time', async () => {
724
- const count = signal(0);
725
- const timeoutDuration = 500;
726
-
727
- const untilPromise = until(count, (value) => value >= 10, { timeout: timeoutDuration });
728
-
729
- // Simulate a change that doesn't meet the condition
730
- setTimeout(() => count.set(1), 10);
731
-
732
- await expect(untilPromise).toThrow(`until: Timeout after ${timeoutDuration}ms.`);
733
- });
734
- ```
735
-
736
- ### elementVisibility
737
-
738
- Tracks if a target DOM element is intersecting with the viewport (or a specified root element) using the `IntersectionObserver` API. This is highly performant for use cases like lazy-loading content or triggering animations when elements scroll into view.
739
-
740
- It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one, allowing for dynamic targets. The returned signal emits the full `IntersectionObserverEntry` object (or `undefined`) & exposes a sub-signal `.visible` which is just a boolean signal for ease of use
741
-
742
- ```typescript
743
- import { Component, effect, ElementRef, viewChild, computed } from '@angular/core';
744
- import { elementVisibility } from '@mmstack/primitives';
745
-
746
- @Component({
747
- selector: 'app-lazy-load-item',
748
- standalone: true,
749
- template: `
750
- <div #itemToObserve style="height: 300px; margin-top: 100vh; border: 2px solid green;">
751
- @if (intersectionEntry.visible()) {
752
- <p>This content was lazy-loaded because it became visible!</p>
753
- } @else {
754
- <p>Item is off-screen. Scroll down to load it.</p>
755
- }
756
- </div>
757
- `,
758
- })
759
- export class LazyLoadItemComponent {
760
- readonly itemRef = viewChild.required<ElementRef<HTMLDivElement>>('itemToObserve', {
761
- read: ElementRef,
762
- });
763
-
764
- // Observe the element, get the full IntersectionObserverEntry
765
- readonly intersectionEntry = elementVisibility(this.itemRef);
766
-
767
- constructor() {
768
- effect(() => {
769
- if (this.intersectionEntry.visible()) {
770
- console.log('Item is now visible!', this.intersectionEntry());
771
- } else {
772
- console.log('Item is no longer visible or not yet visible.');
773
- }
774
- });
775
- }
776
- }
777
- ```
1
+ # @mmstack/primitives
2
+
3
+ A collection of utility functions and primitives designed to enhance development with Angular Signals, providing helpful patterns and inspired by features from other reactive libraries. All value helpers also use pure derivations (no effects/RxJS).
4
+
5
+ [![npm version](https://badge.fury.io/js/%40mmstack%2Fprimitives.svg)](https://badge.fury.io/js/%40mmstack%2Fprimitives)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/packages/primitives/LICENSE)
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install @mmstack/primitives
12
+ ```
13
+
14
+ ## Primitives
15
+
16
+ This library provides the following primitives:
17
+
18
+ - `debounced` - Creates a writable signal whose value updates are debounced after set/update.
19
+ - `throttled` - Creates a writable signal whose value updates are rate-limited.
20
+ - `mutable` - A signal variant allowing in-place mutations while triggering updates.
21
+ - `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
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.
24
+ - `withHistory` - Enhances a signal with a complete undo/redo history stack.
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.
28
+ - `nestedEffect` - Creates an effect with a hierarchical lifetime, enabling fine-grained, conditional side-effects.
29
+ - `toWritable` - Converts a read-only signal to writable using custom write logic.
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.
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
43
+ - `until` - Creates a Promise that resolves when a signal's value meets a specific condition.
44
+
45
+ ---
46
+
47
+ ### debounced
48
+
49
+ Creates a WritableSignal where the propagation of its value (after calls to .set() or .update()) is delayed. The publicly readable signal value updates only after a specified time (ms) has passed without further set/update calls. It also includes an .original property, which is a Signal reflecting the value immediately after set/update is called.
50
+
51
+ ```typescript
52
+ import { Component, signal, effect } from '@angular/core';
53
+ import { debounced, debounce } from '@mmstack/primitives';
54
+ import { FormsModule } from '@angular/forms';
55
+
56
+ @Component({
57
+ selector: 'app-debounced',
58
+ template: `<input [(ngModel)]="searchTerm" />`,
59
+ })
60
+ export class SearchComponent {
61
+ searchTerm = debounced('', { ms: 300 }); // Debounce for 300ms
62
+
63
+ constructor() {
64
+ effect(() => {
65
+ // Runs 300ms after the user stops typing
66
+ console.log('Perform search for:', this.searchTerm());
67
+ });
68
+ effect(() => {
69
+ // Runs immediately on input change
70
+ console.log('Input value:', this.searchTerm.original());
71
+ });
72
+ }
73
+ }
74
+ ```
75
+
76
+ You can also debounce an existing signal:
77
+
78
+ ```typescript
79
+ import { debounce } from '@mmstack/primitives';
80
+
81
+ const query = signal('');
82
+ const debouncedQuery = debounce(query, { ms: 300 });
83
+ ```
84
+
85
+ ### throttled
86
+
87
+ Creates a WritableSignal whose value is rate-limited. It ensures that the public-facing signal only updates at most once per specified time interval (ms). It uses a trailing-edge strategy, meaning it updates with the most recent value at the end of the interval. This is useful for handling high-frequency events like scrolling or mouse movement without overwhelming your application's reactivity.
88
+
89
+ ```typescript
90
+ import { Component, signal, effect } from '@angular/core';
91
+ import { throttled } from '@mmstack/primitives';
92
+ import { JsonPipe } from '@angular/common';
93
+
94
+ @Component({
95
+ selector: 'app-throttle-demo',
96
+ standalone: true,
97
+ imports: [JsonPipe],
98
+ template: `
99
+ <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here to see updates...</div>
100
+ <p><b>Original Position:</b> {{ position.original() | json }}</p>
101
+ <p><b>Throttled Position:</b> {{ position() | json }}</p>
102
+ `,
103
+ })
104
+ export class ThrottleDemoComponent {
105
+ // Throttle updates to at most once every 200ms
106
+ position = throttled({ x: 0, y: 0 }, { ms: 200 });
107
+
108
+ constructor() {
109
+ // This effect runs on every single mouse move event.
110
+ effect(() => {
111
+ // console.log('Original value updated:', this.position.original());
112
+ });
113
+ // This effect will only run at most every 200ms.
114
+ effect(() => {
115
+ console.log('Throttled value updated:', this.position());
116
+ });
117
+ }
118
+
119
+ onMouseMove(event: MouseEvent) {
120
+ this.position.set({ x: event.offsetX, y: event.offsetY });
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### mutable
126
+
127
+ Creates a MutableSignal, a signal variant designed for scenarios where you want to perform in-place mutations on objects or arrays held within the signal, while still ensuring Angular's change detection is correctly triggered. It provides .mutate() and .inline() methods alongside the standard .set() and .update(). Please note that any computeds, which resolve non-primitive values from a mutable require equals to be set to false.
128
+
129
+ ```typescript
130
+ import { Component, computed, effect } from '@angular/core';
131
+ import { mutable } from '@mmstack/primitives';
132
+ import { FormsModule } from '@angular/forms';
133
+
134
+ @Component({
135
+ selector: 'app-mutable',
136
+ template: ` <button (click)="incrementAge()">inc</button> `,
137
+ })
138
+ export class SearchComponent {
139
+ user = mutable({ name: { first: 'John', last: 'Doe' }, age: 30 });
140
+
141
+ constructor() {
142
+ effect(() => {
143
+ // Runs every time user is mutated
144
+ console.log(this.user());
145
+ });
146
+
147
+ const age = computed(() => this.user().age);
148
+
149
+ effect(() => {
150
+ // Runs every time age changes
151
+ console.log(age());
152
+ });
153
+
154
+ const name = computed(() => this.user().name);
155
+ effect(() => {
156
+ // Doesnt run if user changes, unless name is destructured
157
+ console.log(name());
158
+ });
159
+
160
+ const name2 = computed(() => this.user().name, {
161
+ equal: () => false,
162
+ });
163
+
164
+ effect(() => {
165
+ // Runs every time user changes (even if name did not change)
166
+ console.log(name2());
167
+ });
168
+ }
169
+
170
+ incrementAge() {
171
+ user.mutate((prev) => {
172
+ prev.age++;
173
+ return prev;
174
+ });
175
+ }
176
+
177
+ incrementInline() {
178
+ user.inline((prev) => {
179
+ prev.age++;
180
+ });
181
+ }
182
+ }
183
+ ```
184
+
185
+ ### stored
186
+
187
+ Creates a WritableSignal whose state is automatically synchronized with persistent storage (like localStorage or sessionStorage), providing a fallback value when no data is found or fails to parse.
188
+
189
+ It handles Server-Side Rendering (SSR) gracefully, allows dynamic storage keys, custom serialization/deserialization, custom storage providers, and optional synchronization across browser tabs via the storage event. It returns a StoredSignal<T> which includes a .clear() method and a reactive .key signal.
190
+
191
+ ```typescript
192
+ import { Component, effect, signal } from '@angular/core';
193
+ import { stored } from '@mmstack/primitives';
194
+ // import { FormsModule } from '@angular/forms'; // Needed for ngModel
195
+
196
+ @Component({
197
+ selector: 'app-theme-selector',
198
+ standalone: true,
199
+ // imports: [FormsModule], // Import if using ngModel
200
+ template: `
201
+ Theme:
202
+ <select [value]="theme()" (change)="theme.set($event.target.value)">
203
+ <option value="light">Light</option>
204
+ <option value="dark">Dark</option>
205
+ <option value="system">System</option>
206
+ </select>
207
+ <button (click)="theme.clear()">Reset Theme</button>
208
+ <p>Using storage key: {{ theme.key() }}</p>
209
+ `,
210
+ })
211
+ export class ThemeSelectorComponent {
212
+ // Persist theme preference in localStorage, default to 'system'
213
+ theme = stored<'light' | 'dark' | 'system'>('system', {
214
+ key: 'user-theme',
215
+ syncTabs: true, // Sync theme choice across tabs
216
+ });
217
+
218
+ constructor() {
219
+ effect(() => {
220
+ console.log(`Theme set to: ${this.theme()}`);
221
+ // Logic to apply theme (e.g., add class to body)
222
+ document.body.className = `theme-${this.theme()}`;
223
+ });
224
+ }
225
+ }
226
+ ```
227
+
228
+ ### piped
229
+
230
+ Adds two fluent APIs to signals:
231
+
232
+ - **`.map(...transforms, [options])`** compose pure, synchronous value→value transforms. Returns a computed signal that remains pipeable.
233
+ - **`.pipe(...operators)`** – compose operators (signal→signal), useful for combining signals or reusable projections.
234
+
235
+ ```typescript
236
+ import { piped, pipeable, select, combineWith } from '@mmstack/primitives';
237
+ import { signal } from '@angular/core';
238
+
239
+ const count = piped(1);
240
+
241
+ // Map: value -> value
242
+ const label = count.map(
243
+ (n) => n * 2,
244
+ (n) => (num: n),
245
+ { equal: (a, b) => a.num === b.num },
246
+ );
247
+
248
+ // Pipe: signal -> signal
249
+ const base = pipeable(signal(10));
250
+ const total = count.pipe(select((n) => n * 3)).pipe(combineWith(count, (a, b) => a + b));
251
+
252
+ label(); // e.g., "#2"
253
+ total(); // reactive sum
254
+ ```
255
+
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
362
+
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.
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
+
369
+ ```typescript
370
+ import { Component, signal } from '@angular/core';
371
+ import { indexArray, keyArray, mutable } from '@mmstack/primitives';
372
+
373
+ @Component({
374
+ selector: 'app-map-demo',
375
+ template: `
376
+ <ul>
377
+ @for (item of displayItems(); track item) {
378
+ <li>{{ item() }}</li>
379
+ @if ($first) {
380
+ <button (click)="updateFirst(item)">Update First</button>
381
+ }
382
+ }
383
+ </ul>
384
+ <button (click)="addItem()">Add</button>
385
+ `,
386
+ })
387
+ export class ListComponent {
388
+ readonly sourceItems = signal([
389
+ { id: 1, name: 'A' },
390
+ { id: 2, name: 'B' },
391
+ ]);
392
+
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
+
400
+
401
+ addItem() {
402
+ this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
403
+ }
404
+
405
+ updateFirst() {
406
+ this.sourceItems.update((items) => {
407
+ items[0] = { ...items[0], name: items[0].name + '+' };
408
+ return [...items]; // New array, but indexArray keeps stable signals
409
+ });
410
+ }
411
+
412
+ // since the underlying source is a signal we can also create updaters in the mapper
413
+ readonly updatableItems = indexArray(this.sourceItems, (child, index) => {
414
+
415
+ return {
416
+ value: computed(() => `Item ${index}: ${child().name}`))
417
+ updateName: () => child.update((cur) => ({...cur, name: cur.name + '+'}))
418
+ };
419
+ });
420
+
421
+
422
+ // since the underlying source is a WritableSignal we can also create updaters in the mapper
423
+ readonly writableItems = indexArray(this.sourceItems, (child, index) => {
424
+
425
+ return {
426
+ value: computed(() => `Item ${index}: ${child().name}`))
427
+ updateName: () => child.update((cur) => ({...cur, name: cur.name + '+'}))
428
+ };
429
+ });
430
+
431
+ // if the source is a mutable signal we can even update them inline
432
+ readonly sourceItems = mutable([
433
+ { id: 1, name: 'A' },
434
+ { id: 2, name: 'B' },
435
+ ]);
436
+
437
+ readonly mutableItems = indexArray(this.sourceItems, (child, index) => {
438
+
439
+ return {
440
+ value: computed(() => `Item ${index}: ${child().name}`))
441
+ updateName: () => child.inline((cur) => {
442
+ cur.name += '+';
443
+ })
444
+ };
445
+ });
446
+ }
447
+ ```
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
+
505
+ ### nestedEffect
506
+
507
+ Creates an effect that can be nested, similar to SolidJS's `createEffect`.
508
+
509
+ This primitive enables true hierarchical reactivity. A `nestedEffect` created within another `nestedEffect` is **automatically destroyed and recreated** when the parent re-runs.
510
+
511
+ It automatically handles injector propagation and lifetime management, allowing you to create fine-grained, conditional side-effects that only track dependencies when they are "live". This is a powerful optimization for scenarios where a "hot" signal (which changes often) should only be tracked when a "cold" signal (a condition that changes rarely) is true.
512
+
513
+ ```ts
514
+ import { Component, signal } from '@angular/core';
515
+ import { nestedEffect } from '@mmstack/primitives';
516
+
517
+ @Component({ selector: 'app-nested-demo' })
518
+ export class NestedDemoComponent {
519
+ // `coldGuard` changes rarely
520
+ readonly coldGuard = signal(false);
521
+ // `hotSignal` changes very often
522
+ readonly hotSignal = signal(0);
523
+
524
+ constructor() {
525
+ // A standard effect would track *both* signals and run
526
+ // every time `hotSignal` changes, even if `coldGuard` is false.
527
+ // effect(() => {
528
+ // if (this.coldGuard()) {
529
+ // console.log('Hot signal is:', this.hotSignal());
530
+ // }
531
+ // });
532
+
533
+ // `nestedEffect` solves this:
534
+ nestedEffect(() => {
535
+ // This outer effect ONLY tracks `coldGuard`.
536
+ // It does not track `hotSignal`.
537
+ if (this.coldGuard()) {
538
+ // This inner effect is CREATED when coldGuard is true
539
+ // and DESTROYED when it becomes false.
540
+ nestedEffect(() => {
541
+ // It only tracks `hotSignal` while it exists.
542
+ console.log('Hot signal is:', this.hotSignal());
543
+ });
544
+ }
545
+ });
546
+ }
547
+ }
548
+ ```
549
+
550
+ #### Advanced Example: Fine-grained Lists
551
+
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.
553
+
554
+ ```ts
555
+ import { Component, signal, computed } from '@angular/core';
556
+ import { indexArray, nestedEffect } from '@mmstack/primitives';
557
+
558
+ @Component({ selector: 'app-list-demo' })
559
+ export class ListDemoComponent {
560
+ readonly users = signal([
561
+ { id: 1, name: 'Alice' },
562
+ { id: 2, name: 'Bob' },
563
+ ]);
564
+
565
+ // indexArray creates stable signals for each item
566
+ readonly mappedUsers = indexArray(
567
+ this.users,
568
+ (userSignal, index) => {
569
+ // Create a side-effect tied to THIS item's lifetime
570
+ const effectRef = nestedEffect(() => {
571
+ // This only runs if `userSignal` (this specific user) changes.
572
+ console.log(`User ${index} updated:`, userSignal().name);
573
+
574
+ // e.g., updateAGGridRow(index, userSignal());
575
+ });
576
+
577
+ // Return the data and the cleanup logic
578
+ return {
579
+ label: computed(() => `User: ${userSignal().name}`),
580
+ // This function will be called by `onDestroy`
581
+ _destroy: () => effectRef.destroy(),
582
+ };
583
+ },
584
+ {
585
+ // When indexArray removes an item, it calls `onDestroy`
586
+ onDestroy: (mappedItem) => {
587
+ mappedItem._destroy(); // Manually destroy the nested effect
588
+ },
589
+ },
590
+ );
591
+ }
592
+ ```
593
+
594
+ ### toWritable
595
+
596
+ A utility function that converts a read-only Signal into a WritableSignal by allowing you to provide custom implementations for the .set() and .update() methods. This is useful for creating controlled write access to signals that are naturally read-only (like those created by computed). This is used under the hood in derived.
597
+
598
+ ```typescript
599
+ import { Component, signal, effect } from '@angular/core';
600
+ import { toWritable } from '@mmstack/primitives';
601
+
602
+ const user = signal({ name: 'John' });
603
+
604
+ const name = toWritable(
605
+ computed(() => user().name),
606
+ (name) => user.update((prev) => ({ ...prev, name })),
607
+ ); // WritableSignal<string> bound to user signal
608
+ ```
609
+
610
+ ### derived
611
+
612
+ Creates a WritableSignal that represents a part of another source WritableSignal (e.g., an object property or an array element), enabling two-way data binding. Changes to the source update the derived signal, and changes to the derived signal (via .set() or .update()) update the source signal accordingly.
613
+
614
+ ```typescript
615
+ const user = signal({ name: 'John' });
616
+
617
+ const name = derived(user, 'name'); // WritableSignal<string>, which updates user signal & reacts to changes in the name property
618
+
619
+ // Full syntax example
620
+ const name2 = derived(user, {
621
+ from: (u) => u.name,
622
+ onChange: (name) => user.update((prev) => ({ ...prev, name })),
623
+ });
624
+ ```
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
+
704
+ ### withHistory
705
+
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.
707
+
708
+ ```typescript
709
+ import { FormsModule } from '@angular/forms';
710
+ import { JsonPipe } from '@angular/common';
711
+ import { withHistory } from '@mmstack/primitives';
712
+ import { Component, signal, effect } from '@angular/core';
713
+
714
+ @Component({
715
+ selector: 'app-history-demo',
716
+ standalone: true,
717
+ imports: [FormsModule, JsonPipe],
718
+ template: `
719
+ <h4>Simple Text Editor</h4>
720
+ <textarea [(ngModel)]="text" rows="4" cols="50"></textarea>
721
+ <div class="buttons" style="margin-top: 8px; display: flex; gap: 8px;">
722
+ <button (click)="text.undo()" [disabled]="!text.canUndo()">Undo</button>
723
+ <button (click)="text.redo()" [disabled]="!text.canRedo()">Redo</button>
724
+ <button (click)="text.clear()" [disabled]="!text.canClear()">Clear History</button>
725
+ </div>
726
+ <p>History Stack:</p>
727
+ <pre>{{ text.history() | json }}</pre>
728
+ `,
729
+ })
730
+ export class HistoryDemoComponent {
731
+ // Create a signal and immediately enhance it with history capabilities.
732
+ text = withHistory(signal('Hello, type something!'), { maxSize: 10 });
733
+
734
+ constructor() {
735
+ // You can react to history changes as well
736
+ effect(() => {
737
+ console.log('History stack changed:', this.text.history());
738
+ });
739
+ }
740
+ }
741
+ ```
742
+
743
+ ### sensor
744
+
745
+ ### sensor
746
+
747
+ The `sensor()` facade provides a unified way to create various reactive sensor signals that track browser events, states, and user preferences. You specify the type of sensor you want (e.g., `'mousePosition'`, `'networkStatus'`, `'windowSize'`, `'dark-mode'`), and it returns the corresponding signal, often with specific properties or methods. These primitives are generally SSR-safe and handle their own event listener cleanup.
748
+
749
+ You can either use the `sensor('sensorType', options)` facade or import the specific sensor functions directly if you prefer.
750
+
751
+ **Facade Usage Example:**
752
+
753
+ ```typescript
754
+ import { sensor } from '@mmstack/primitives';
755
+ import { effect } from '@angular/core';
756
+
757
+ const network = sensor('networkStatus');
758
+ const mouse = sensor('mousePosition', { throttle: 50, coordinateSpace: 'page' });
759
+ const winSize = sensor('windowSize', { throttle: 150 });
760
+ const isDark = sensor('dark-mode');
761
+
762
+ effect(() => console.log('Online:', network().isOnline));
763
+ effect(() => console.log('Mouse X:', mouse().x));
764
+ effect(() => console.log('Window Width:', winSize().width));
765
+ effect(() => console.log('Dark Mode Preferred:', isDark()));
766
+ ```
767
+
768
+ Individual sensors available through the facade or direct import:
769
+
770
+ #### mousePosition
771
+
772
+ Tracks the mouse cursor's position. By default, updates are throttled to 100ms. It provides the main throttled signal and an .unthrottled property to access the raw updates.
773
+
774
+ Key Options: target, coordinateSpace ('client' or 'page'), touch (boolean), throttle (ms).
775
+
776
+ ```typescript
777
+ import { Component, effect } from '@angular/core';
778
+ import { sensor } from '@mmstack/primitives'; // Or import { mousePosition }
779
+ import { JsonPipe } from '@angular/common';
780
+
781
+ @Component({
782
+ selector: 'app-mouse-tracker',
783
+ standalone: true,
784
+ imports: [JsonPipe],
785
+ template: `
786
+ <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here...</div>
787
+ <p><b>Throttled Position:</b> {{ mousePos() | json }}</p>
788
+ <p><b>Unthrottled Position:</b> {{ mousePos.unthrottled() | json }}</p>
789
+ `,
790
+ })
791
+ export class MouseTrackerComponent {
792
+ // Using the facade
793
+ readonly mousePos = sensor('mousePosition', { coordinateSpace: 'page', throttle: 200 });
794
+ // Or direct import:
795
+ // readonly mousePos = mousePosition({ coordinateSpace: 'page', throttle: 200 });
796
+
797
+ // Note: The (mousemove) event here is just to show the example area works.
798
+ // The mousePosition sensor binds its own listeners based on the target option.
799
+ onMouseMove(event: MouseEvent) {
800
+ // No need to call set, mousePosition handles it.
801
+ }
802
+
803
+ constructor() {
804
+ effect(() => console.log('Throttled Mouse:', this.mousePos()));
805
+ effect(() => console.log('Unthrottled Mouse:', this.mousePos.unthrottled()));
806
+ }
807
+ }
808
+ ```
809
+
810
+ #### networkStatus
811
+
812
+ Tracks the browser's online/offline status. The returned signal is a boolean (`true` for online) and has an attached `.since` signal indicating when the status last changed.
813
+
814
+ ```typescript
815
+ import { Component, effect } from '@angular/core';
816
+ import { sensor } from '@mmstack/primitives'; // Or import { networkStatus }
817
+ import { DatePipe } from '@angular/common';
818
+
819
+ @Component({
820
+ selector: 'app-network-info',
821
+ standalone: true,
822
+ imports: [DatePipe],
823
+ template: `
824
+ @if (netStatus()) {
825
+ <p>✅ Online (Since: {{ netStatus.since() | date: 'short' }})</p>
826
+ } @else {
827
+ <p>❌ Offline (Since: {{ netStatus.since() | date: 'short' }})</p>
828
+ }
829
+ `,
830
+ })
831
+ export class NetworkInfoComponent {
832
+ readonly netStatus = sensor('networkStatus');
833
+
834
+ constructor() {
835
+ effect(() => {
836
+ console.log('Network online:', this.netStatus(), 'Since:', this.netStatus.since());
837
+ });
838
+ }
839
+ }
840
+ ```
841
+
842
+ #### pageVisibility
843
+
844
+ Tracks the page's visibility state (e.g., 'visible', 'hidden') using the Page Visibility API.
845
+
846
+ ```typescript
847
+ import { Component, effect } from '@angular/core';
848
+ import { sensor } from '@mmstack/primitives'; // Or import { pageVisibility }
849
+
850
+ @Component({
851
+ selector: 'app-visibility-logger',
852
+ standalone: true,
853
+ template: `<p>Page is currently: {{ visibility() }}</p>`,
854
+ })
855
+ export class VisibilityLoggerComponent {
856
+ readonly visibility = sensor('pageVisibility');
857
+
858
+ constructor() {
859
+ effect(() => {
860
+ console.log('Page visibility changed to:', this.visibility());
861
+ if (this.visibility() === 'hidden') {
862
+ // Perform cleanup or pause tasks
863
+ }
864
+ });
865
+ }
866
+ }
867
+ ```
868
+
869
+ #### windowSize
870
+
871
+ Tracks the browser window's inner dimensions (width and height). Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
872
+
873
+ ```typescript
874
+ import { Component, effect, computed } from '@angular/core';
875
+ import { sensor } from '@mmstack/primitives'; // Or import { windowSize }
876
+
877
+ @Component({
878
+ selector: 'app-responsive-display',
879
+ standalone: true,
880
+ template: `
881
+ <p>Current Window Size: {{ winSize().width }}px x {{ winSize().height }}px</p>
882
+ <p>Unthrottled: W: {{ winSize.unthrottled().width }} H: {{ winSize.unthrottled().height }}</p>
883
+ @if (isMobileDisplay()) {
884
+ <p>Displaying mobile layout.</p>
885
+ } @else {
886
+ <p>Displaying desktop layout.</p>
887
+ }
888
+ `,
889
+ })
890
+ export class ResponsiveDisplayComponent {
891
+ readonly winSize = sensor('windowSize', { throttle: 150 });
892
+ // Or: readonly winSize = windowSize({ throttle: 150 });
893
+
894
+ readonly isMobileDisplay = computed(() => this.winSize().width < 768);
895
+
896
+ constructor() {
897
+ effect(() => console.log('Window Size (Throttled):', this.winSize()));
898
+ }
899
+ }
900
+ ```
901
+
902
+ #### scrollPosition
903
+
904
+ Tracks the scroll position (x, y) of the window or a specified HTML element. Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
905
+
906
+ ```typescript
907
+ import { Component, effect, ElementRef, viewChild } from '@angular/core';
908
+ import { sensor } from '@mmstack/primitives'; // Or import { scrollPosition }
909
+ import { JsonPipe } from '@angular/common';
910
+
911
+ @Component({
912
+ selector: 'app-scroll-indicator',
913
+ standalone: true,
914
+ imports: [JsonPipe],
915
+ template: `
916
+ <div style="height: 100px; border-bottom: 2px solid red; position: fixed; top: 0; left: 0; width: 100%; background: white; z-index: 10;">
917
+ Page Scroll Y: {{ pageScroll().y }}px
918
+ <p>Unthrottled Y: {{ pageScroll.unthrottled().y }}</p>
919
+ </div>
920
+ <div #scrollableContent style="height: 2000px; padding-top: 120px;">Scroll down...</div>
921
+ `,
922
+ })
923
+ export class ScrollIndicatorComponent {
924
+ readonly pageScroll = sensor('scrollPosition', { throttle: 50 });
925
+ // Or: readonly pageScroll = scrollPosition({ throttle: 50 });
926
+
927
+ constructor() {
928
+ effect(() => {
929
+ // Example: Change header style based on scroll
930
+ console.log('Page scroll Y (Throttled):', this.pageScroll().y);
931
+ });
932
+ }
933
+ }
934
+ ```
935
+
936
+ #### mediaQuery, prefersDarkMode() & prefersReducedMotion()
937
+
938
+ A generic mediaQuery primitive, you can use directly for any CSS media query. Two specific versions have been created for `prefersDarkMode()` & `prefersReducedMotion()`.
939
+ Reacts to changes in preferences & exposes a `Signal<boolean>`.
940
+
941
+ ```typescript
942
+ import { Component, effect } from '@angular/core';
943
+ import { mediaQuery, prefersDarkMode, prefersReducedMotion } from '@mmstack/primitives'; // Direct import
944
+
945
+ @Component({
946
+ selector: 'app-layout-checker',
947
+ standalone: true,
948
+ template: `
949
+ @if (isLargeScreen()) {
950
+ <p>Using large screen layout.</p>
951
+ } @else {
952
+ <p>Using small screen layout.</p>
953
+ }
954
+ `,
955
+ })
956
+ export class LayoutCheckerComponent {
957
+ readonly isLargeScreen = mediaQuery('(min-width: 1280px)');
958
+ readonly prefersDark = prefersDarkMode(); // is just a pre-defined mediaQuery signal
959
+ readonly prefersReducedMotion = prefersReducedMotion(); // is just a pre-defined mediaQuery signal
960
+ constructor() {
961
+ effect(() => {
962
+ console.log('Is large screen:', this.isLargeScreen());
963
+ });
964
+ }
965
+ }
966
+ ```
967
+
968
+ ### until
969
+
970
+ The `until` primitive provides a powerful way to bridge Angular's reactive signals with imperative, Promise-based asynchronous code. It returns a Promise that resolves when the value of a given signal satisfies a specified predicate function.
971
+
972
+ This is particularly useful for:
973
+
974
+ - Orchestrating complex sequences of operations (e.g., waiting for data to load or for a user action to complete before proceeding).
975
+ - Writing tests where you need to await a certain state before making assertions.
976
+ - Integrating with other Promise-based APIs.
977
+
978
+ It also supports optional timeouts and automatic cancellation via DestroyRef if the consuming context (like a component) is destroyed before the condition is met.
979
+
980
+ ```typescript
981
+ import { signal } from '@angular/core';
982
+ import { until } from '@mmstack/primitives';
983
+
984
+ it('should reject on timeout if the condition is not met in time', async () => {
985
+ const count = signal(0);
986
+ const timeoutDuration = 500;
987
+
988
+ const untilPromise = until(count, (value) => value >= 10, { timeout: timeoutDuration });
989
+
990
+ // Simulate a change that doesn't meet the condition
991
+ setTimeout(() => count.set(1), 10);
992
+
993
+ await expect(untilPromise).toThrow(`until: Timeout after ${timeoutDuration}ms.`);
994
+ });
995
+ ```
996
+
997
+ ### elementVisibility
998
+
999
+ Tracks if a target DOM element is intersecting with the viewport (or a specified root element) using the `IntersectionObserver` API. This is highly performant for use cases like lazy-loading content or triggering animations when elements scroll into view.
1000
+
1001
+ It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one, allowing for dynamic targets. The returned signal emits the full `IntersectionObserverEntry` object (or `undefined`) & exposes a sub-signal `.visible` which is just a boolean signal for ease of use
1002
+
1003
+ ```typescript
1004
+ import { Component, effect, ElementRef, viewChild, computed } from '@angular/core';
1005
+ import { elementVisibility } from '@mmstack/primitives';
1006
+
1007
+ @Component({
1008
+ selector: 'app-lazy-load-item',
1009
+ standalone: true,
1010
+ template: `
1011
+ <div #itemToObserve style="height: 300px; margin-top: 100vh; border: 2px solid green;">
1012
+ @if (intersectionEntry.visible()) {
1013
+ <p>This content was lazy-loaded because it became visible!</p>
1014
+ } @else {
1015
+ <p>Item is off-screen. Scroll down to load it.</p>
1016
+ }
1017
+ </div>
1018
+ `,
1019
+ })
1020
+ export class LazyLoadItemComponent {
1021
+ readonly itemRef = viewChild.required<ElementRef<HTMLDivElement>>('itemToObserve', {
1022
+ read: ElementRef,
1023
+ });
1024
+
1025
+ // Observe the element, get the full IntersectionObserverEntry
1026
+ readonly intersectionEntry = elementVisibility(this.itemRef);
1027
+
1028
+ constructor() {
1029
+ effect(() => {
1030
+ if (this.intersectionEntry.visible()) {
1031
+ console.log('Item is now visible!', this.intersectionEntry());
1032
+ } else {
1033
+ console.log('Item is no longer visible or not yet visible.');
1034
+ }
1035
+ });
1036
+ }
1037
+ }
1038
+ ```