@mmstack/primitives 19.1.2 → 19.2.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 +262 -0
- package/fesm2022/mmstack-primitives.mjs +586 -18
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +3 -0
- package/lib/get-signal-equality.d.ts +5 -0
- package/lib/sensors/index.d.ts +5 -0
- package/lib/sensors/media-query.d.ts +94 -0
- package/lib/sensors/mouse-position.d.ts +75 -0
- package/lib/sensors/network-status.d.ts +20 -0
- package/lib/sensors/page-visibility.d.ts +38 -0
- package/lib/sensors/sensor.d.ts +56 -0
- package/lib/throttled.d.ts +75 -0
- package/lib/until.d.ts +51 -0
- package/lib/with-history.d.ts +81 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,11 +16,15 @@ npm install @mmstack/primitives
|
|
|
16
16
|
This library provides the following primitives:
|
|
17
17
|
|
|
18
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.
|
|
19
20
|
- `mutable` - A signal variant allowing in-place mutations while triggering updates.
|
|
20
21
|
- `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
|
|
22
|
+
- `withHistory` - Enhances a signal with a complete undo/redo history stack.
|
|
21
23
|
- `mapArray` - Maps a reactive array efficently into an array of stable derivations.
|
|
22
24
|
- `toWritable` - Converts a read-only signal to writable using custom write logic.
|
|
23
25
|
- `derived` - Creates a signal with two-way binding to a source signal.
|
|
26
|
+
- `sensor` - A facade function to create various reactive sensor signals (e.g., mouse position, network status, page visibility, dark mode preference)." (This makes it flow a bit better and more accurately lists what the facade produces.)
|
|
27
|
+
- `until` - Creates a Promise that resolves when a signal's value meets a specific condition.
|
|
24
28
|
|
|
25
29
|
---
|
|
26
30
|
|
|
@@ -62,6 +66,46 @@ const query = signal('');
|
|
|
62
66
|
const debouncedQuery = debounce(query, { ms: 300 });
|
|
63
67
|
```
|
|
64
68
|
|
|
69
|
+
### throttled
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { Component, signal, effect } from '@angular/core';
|
|
75
|
+
import { throttled } from '@mmstack/primitives';
|
|
76
|
+
import { JsonPipe } from '@angular/common';
|
|
77
|
+
|
|
78
|
+
@Component({
|
|
79
|
+
selector: 'app-throttle-demo',
|
|
80
|
+
standalone: true,
|
|
81
|
+
imports: [JsonPipe],
|
|
82
|
+
template: `
|
|
83
|
+
<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>
|
|
84
|
+
<p><b>Original Position:</b> {{ position.original() | json }}</p>
|
|
85
|
+
<p><b>Throttled Position:</b> {{ position() | json }}</p>
|
|
86
|
+
`,
|
|
87
|
+
})
|
|
88
|
+
export class ThrottleDemoComponent {
|
|
89
|
+
// Throttle updates to at most once every 200ms
|
|
90
|
+
position = throttled({ x: 0, y: 0 }, { ms: 200 });
|
|
91
|
+
|
|
92
|
+
constructor() {
|
|
93
|
+
// This effect runs on every single mouse move event.
|
|
94
|
+
effect(() => {
|
|
95
|
+
// console.log('Original value updated:', this.position.original());
|
|
96
|
+
});
|
|
97
|
+
// This effect will only run at most every 200ms.
|
|
98
|
+
effect(() => {
|
|
99
|
+
console.log('Throttled value updated:', this.position());
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onMouseMove(event: MouseEvent) {
|
|
104
|
+
this.position.set({ x: event.offsetX, y: event.offsetY });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
65
109
|
### mutable
|
|
66
110
|
|
|
67
111
|
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.
|
|
@@ -237,3 +281,221 @@ const name2 = derived(user, {
|
|
|
237
281
|
onChange: (name) => user.update((prev) => ({ ...prev, name })),
|
|
238
282
|
});
|
|
239
283
|
```
|
|
284
|
+
|
|
285
|
+
### withHistory
|
|
286
|
+
|
|
287
|
+
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.
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import { FormsModule } from '@angular/forms';
|
|
291
|
+
import { JsonPipe } from '@angular/common';
|
|
292
|
+
|
|
293
|
+
@Component({
|
|
294
|
+
selector: 'app-history-demo',
|
|
295
|
+
standalone: true,
|
|
296
|
+
imports: [FormsModule, JsonPipe],
|
|
297
|
+
template: `
|
|
298
|
+
<h4>Simple Text Editor</h4>
|
|
299
|
+
<textarea [(ngModel)]="text" rows="4" cols="50"></textarea>
|
|
300
|
+
<div class="buttons" style="margin-top: 8px; display: flex; gap: 8px;">
|
|
301
|
+
<button (click)="text.undo()" [disabled]="!text.canUndo()">Undo</button>
|
|
302
|
+
<button (click)="text.redo()" [disabled]="!text.canRedo()">Redo</button>
|
|
303
|
+
<button (click)="text.clear()" [disabled]="!text.canClear()">Clear History</button>
|
|
304
|
+
</div>
|
|
305
|
+
<p>History Stack:</p>
|
|
306
|
+
<pre>{{ text.history() | json }}</pre>
|
|
307
|
+
`,
|
|
308
|
+
})
|
|
309
|
+
export class HistoryDemoComponent {
|
|
310
|
+
// Create a signal and immediately enhance it with history capabilities.
|
|
311
|
+
text = withHistory(signal('Hello, type something!'), { maxSize: 10 });
|
|
312
|
+
|
|
313
|
+
constructor() {
|
|
314
|
+
// You can react to history changes as well
|
|
315
|
+
effect(() => {
|
|
316
|
+
console.log('History stack changed:', this.text.history());
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### sensor
|
|
323
|
+
|
|
324
|
+
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, 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.
|
|
325
|
+
|
|
326
|
+
You can either use the sensor('sensorType', options) facade or import the specific sensor functions directly.
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
import { sensor } from '@mmstack/primitives';
|
|
330
|
+
import { effect } from '@angular/core';
|
|
331
|
+
|
|
332
|
+
const network = sensor('networkStatus');
|
|
333
|
+
const mouse = sensor('mousePosition', { throttle: 50, coordinateSpace: 'page' });
|
|
334
|
+
const isDark = sensor('dark-mode');
|
|
335
|
+
|
|
336
|
+
effect(() => console.log('Online:', network().isOnline));
|
|
337
|
+
effect(() => console.log('Mouse X:', mouse().x));
|
|
338
|
+
effect(() => console.log('Dark Mode Preferred:', isDark()));
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Individual sensors available through the facade or direct import:
|
|
342
|
+
|
|
343
|
+
#### mousePosition
|
|
344
|
+
|
|
345
|
+
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.
|
|
346
|
+
|
|
347
|
+
Key Options: target, coordinateSpace ('client' or 'page'), touch (boolean), throttle (ms).
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
import { Component, effect } from '@angular/core';
|
|
351
|
+
import { sensor } from '@mmstack/primitives'; // Or import { mousePosition }
|
|
352
|
+
import { JsonPipe } from '@angular/common';
|
|
353
|
+
|
|
354
|
+
@Component({
|
|
355
|
+
selector: 'app-mouse-tracker',
|
|
356
|
+
standalone: true,
|
|
357
|
+
imports: [JsonPipe],
|
|
358
|
+
template: `
|
|
359
|
+
<div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here...</div>
|
|
360
|
+
<p><b>Throttled Position:</b> {{ mousePos() | json }}</p>
|
|
361
|
+
<p><b>Unthrottled Position:</b> {{ mousePos.unthrottled() | json }}</p>
|
|
362
|
+
`,
|
|
363
|
+
})
|
|
364
|
+
export class MouseTrackerComponent {
|
|
365
|
+
// Using the facade
|
|
366
|
+
readonly mousePos = sensor('mousePosition', { coordinateSpace: 'page', throttle: 200 });
|
|
367
|
+
// Or direct import:
|
|
368
|
+
// readonly mousePos = mousePosition({ coordinateSpace: 'page', throttle: 200 });
|
|
369
|
+
|
|
370
|
+
// Note: The (mousemove) event here is just to show the example area works.
|
|
371
|
+
// The mousePosition sensor binds its own listeners based on the target option.
|
|
372
|
+
onMouseMove(event: MouseEvent) {
|
|
373
|
+
// No need to call set, mousePosition handles it.
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
constructor() {
|
|
377
|
+
effect(() => console.log('Throttled Mouse:', this.mousePos()));
|
|
378
|
+
effect(() => console.log('Unthrottled Mouse:', this.mousePos.unthrottled()));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
#### networkStatus
|
|
384
|
+
|
|
385
|
+
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.
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import { Component, effect } from '@angular/core';
|
|
389
|
+
import { sensor } from '@mmstack/primitives'; // Or import { networkStatus }
|
|
390
|
+
import { DatePipe } from '@angular/common';
|
|
391
|
+
|
|
392
|
+
@Component({
|
|
393
|
+
selector: 'app-network-info',
|
|
394
|
+
standalone: true,
|
|
395
|
+
imports: [DatePipe],
|
|
396
|
+
template: `
|
|
397
|
+
@if (netStatus()) {
|
|
398
|
+
<p>✅ Online (Since: {{ netStatus.since() | date: 'short' }})</p>
|
|
399
|
+
} @else {
|
|
400
|
+
<p>❌ Offline (Since: {{ netStatus.since() | date: 'short' }})</p>
|
|
401
|
+
}
|
|
402
|
+
`,
|
|
403
|
+
})
|
|
404
|
+
export class NetworkInfoComponent {
|
|
405
|
+
readonly netStatus = sensor('networkStatus');
|
|
406
|
+
|
|
407
|
+
constructor() {
|
|
408
|
+
effect(() => {
|
|
409
|
+
console.log('Network online:', this.netStatus(), 'Since:', this.netStatus.since());
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
#### pageVisibility
|
|
416
|
+
|
|
417
|
+
Tracks the page's visibility state (e.g., 'visible', 'hidden') using the Page Visibility API.
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
import { Component, effect } from '@angular/core';
|
|
421
|
+
import { sensor } from '@mmstack/primitives'; // Or import { pageVisibility }
|
|
422
|
+
|
|
423
|
+
@Component({
|
|
424
|
+
selector: 'app-visibility-logger',
|
|
425
|
+
standalone: true,
|
|
426
|
+
template: `<p>Page is currently: {{ visibility() }}</p>`,
|
|
427
|
+
})
|
|
428
|
+
export class VisibilityLoggerComponent {
|
|
429
|
+
readonly visibility = sensor('pageVisibility');
|
|
430
|
+
|
|
431
|
+
constructor() {
|
|
432
|
+
effect(() => {
|
|
433
|
+
console.log('Page visibility changed to:', this.visibility());
|
|
434
|
+
if (this.visibility() === 'hidden') {
|
|
435
|
+
// Perform cleanup or pause tasks
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
#### mediaQuery, prefersDarkMode() & prefersReducedMotion()
|
|
443
|
+
|
|
444
|
+
A generic mediaQuery primitive, you can use directly for any CSS media query. Two specific versions have been created for `prefersDarkMode()` & `prefersReducedMotion()`.
|
|
445
|
+
Reacts to changes in preferences & exposes a `Signal<boolean>`.
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import { Component, effect } from '@angular/core';
|
|
449
|
+
import { mediaQuery, prefersDarkMode, prefersReducedMotion } from '@mmstack/primitives'; // Direct import
|
|
450
|
+
|
|
451
|
+
@Component({
|
|
452
|
+
selector: 'app-layout-checker',
|
|
453
|
+
standalone: true,
|
|
454
|
+
template: `
|
|
455
|
+
@if (isLargeScreen()) {
|
|
456
|
+
<p>Using large screen layout.</p>
|
|
457
|
+
} @else {
|
|
458
|
+
<p>Using small screen layout.</p>
|
|
459
|
+
}
|
|
460
|
+
`,
|
|
461
|
+
})
|
|
462
|
+
export class LayoutCheckerComponent {
|
|
463
|
+
readonly isLargeScreen = mediaQuery('(min-width: 1280px)');
|
|
464
|
+
readonly prefersDark = prefersDarkMode(); // is just a pre-defined mediaQuery signal
|
|
465
|
+
readonly prefersReducedMotion = prefersReducedMotion(); // is just a pre-defined mediaQuery signal
|
|
466
|
+
constructor() {
|
|
467
|
+
effect(() => {
|
|
468
|
+
console.log('Is large screen:', this.isLargeScreen());
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### until
|
|
475
|
+
|
|
476
|
+
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.
|
|
477
|
+
|
|
478
|
+
This is particularly useful for:
|
|
479
|
+
|
|
480
|
+
- Orchestrating complex sequences of operations (e.g., waiting for data to load or for a user action to complete before proceeding).
|
|
481
|
+
- Writing tests where you need to await a certain state before making assertions.
|
|
482
|
+
- Integrating with other Promise-based APIs.
|
|
483
|
+
|
|
484
|
+
It also supports optional timeouts and automatic cancellation via DestroyRef if the consuming context (like a component) is destroyed before the condition is met.
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
import { signal } from '@angular/core';
|
|
488
|
+
import { until } from '@mmstack/primitives';
|
|
489
|
+
|
|
490
|
+
it('should reject on timeout if the condition is not met in time', async () => {
|
|
491
|
+
const count = signal(0);
|
|
492
|
+
const timeoutDuration = 500;
|
|
493
|
+
|
|
494
|
+
const untilPromise = until(count, (value) => value >= 10, { timeout: timeoutDuration });
|
|
495
|
+
|
|
496
|
+
// Simulate a change that doesn't meet the condition
|
|
497
|
+
setTimeout(() => count.set(1), 10);
|
|
498
|
+
|
|
499
|
+
await expect(untilPromise).toThrow(`until: Timeout after ${timeoutDuration}ms.`);
|
|
500
|
+
});
|
|
501
|
+
```
|