@mmstack/primitives 19.1.1 → 19.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -0
- package/fesm2022/mmstack-primitives.mjs +621 -22
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +3 -0
- package/lib/derived.d.ts +43 -20
- package/lib/get-signal-equality.d.ts +5 -0
- package/lib/map-array.d.ts +3 -0
- package/lib/mutable.d.ts +25 -10
- 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/stored.d.ts +11 -1
- package/lib/throttled.d.ts +75 -0
- package/lib/until.d.ts +50 -0
- package/lib/with-history.d.ts +81 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { untracked, signal, inject, DestroyRef, computed, isSignal, linkedSignal, PLATFORM_ID, isDevMode, effect } from '@angular/core';
|
|
1
|
+
import { untracked, signal, inject, DestroyRef, computed, isSignal, linkedSignal, PLATFORM_ID, ElementRef, isDevMode, effect } from '@angular/core';
|
|
2
2
|
import { isPlatformServer } from '@angular/common';
|
|
3
3
|
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
4
4
|
|
|
@@ -143,7 +143,11 @@ function derived(source, optOrKey, opt) {
|
|
|
143
143
|
? optOrKey.onChange
|
|
144
144
|
: isArray
|
|
145
145
|
? (next) => {
|
|
146
|
-
source.update((cur) =>
|
|
146
|
+
source.update((cur) => {
|
|
147
|
+
const newArray = [...cur];
|
|
148
|
+
newArray[optOrKey] = next;
|
|
149
|
+
return newArray;
|
|
150
|
+
});
|
|
147
151
|
}
|
|
148
152
|
: (next) => {
|
|
149
153
|
source.update((cur) => ({ ...cur, [optOrKey]: next }));
|
|
@@ -252,6 +256,9 @@ function isDerivation(sig) {
|
|
|
252
256
|
* { equal: (a, b) => a.id === b.id && a.name === b.name }
|
|
253
257
|
* );
|
|
254
258
|
* ```
|
|
259
|
+
* @remarks
|
|
260
|
+
* This function achieves its high performance by leveraging the new `linkedSignal`
|
|
261
|
+
* API from Angular, which allows for efficient memoization and reuse of array items.
|
|
255
262
|
*/
|
|
256
263
|
function mapArray(source, map, opt) {
|
|
257
264
|
const data = isSignal(source) ? source : computed(source);
|
|
@@ -329,6 +336,404 @@ function isMutable(value) {
|
|
|
329
336
|
return 'mutate' in value && typeof value.mutate === 'function';
|
|
330
337
|
}
|
|
331
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Creates a read-only signal that reactively tracks whether a CSS media query
|
|
341
|
+
* string currently matches.
|
|
342
|
+
*
|
|
343
|
+
* It uses `window.matchMedia` to evaluate the query and listen for changes.
|
|
344
|
+
* The primitive is SSR-safe (defaults to `false` on the server) and automatically
|
|
345
|
+
* cleans up its event listeners when the creating context is destroyed.
|
|
346
|
+
*
|
|
347
|
+
* @param query The CSS media query string to evaluate (e.g., `'(min-width: 768px)'`, `'(prefers-color-scheme: dark)'`).
|
|
348
|
+
* @param debugName Optional debug name for the signal.
|
|
349
|
+
* @returns A read-only `Signal<boolean>` which is `true` if the media query
|
|
350
|
+
* currently matches, and `false` otherwise.
|
|
351
|
+
*
|
|
352
|
+
* @remarks
|
|
353
|
+
* - On the server, this signal will always return `false` by default.
|
|
354
|
+
* - It automatically updates if the match status of the media query changes in the browser.
|
|
355
|
+
* - Event listeners are cleaned up automatically via `DestroyRef` if created in an injection context.
|
|
356
|
+
*
|
|
357
|
+
* @example
|
|
358
|
+
* ```ts
|
|
359
|
+
* import { Component, effect } from '@angular/core';
|
|
360
|
+
* import { mediaQuery } from '@mmstack/primitives';
|
|
361
|
+
*
|
|
362
|
+
* @Component({
|
|
363
|
+
* selector: 'app-responsive-layout',
|
|
364
|
+
* template: `
|
|
365
|
+
* @if (isDesktop()) {
|
|
366
|
+
* <p>Showing desktop layout.</p>
|
|
367
|
+
* } @else {
|
|
368
|
+
* <p>Showing mobile layout.</p>
|
|
369
|
+
* }
|
|
370
|
+
* `
|
|
371
|
+
* })
|
|
372
|
+
* export class ResponsiveLayoutComponent {
|
|
373
|
+
* readonly isDesktop = mediaQuery('(min-width: 1024px)');
|
|
374
|
+
*
|
|
375
|
+
* constructor() {
|
|
376
|
+
* effect(() => {
|
|
377
|
+
* console.log('Is desktop view:', this.isDesktop());
|
|
378
|
+
* });
|
|
379
|
+
* }
|
|
380
|
+
* }
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
function mediaQuery(query, debugName) {
|
|
384
|
+
if (isPlatformServer(inject(PLATFORM_ID)))
|
|
385
|
+
return computed(() => false, { debugName });
|
|
386
|
+
const mediaQueryList = window.matchMedia(query);
|
|
387
|
+
const state = signal(mediaQueryList.matches, { debugName });
|
|
388
|
+
const handleChange = (event) => {
|
|
389
|
+
state.set(event.matches);
|
|
390
|
+
};
|
|
391
|
+
mediaQueryList.addEventListener('change', handleChange);
|
|
392
|
+
inject(DestroyRef).onDestroy(() => {
|
|
393
|
+
mediaQueryList.removeEventListener('change', handleChange);
|
|
394
|
+
});
|
|
395
|
+
return state.asReadonly();
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
399
|
+
* for a dark color scheme using the `(prefers-color-scheme: dark)` media query.
|
|
400
|
+
*
|
|
401
|
+
* This is a convenience wrapper around the generic `mediaQuery` primitive.
|
|
402
|
+
* It's SSR-safe (defaults to `false` on the server) and automatically
|
|
403
|
+
* cleans up its event listeners.
|
|
404
|
+
*
|
|
405
|
+
* @param debugName Optional debug name for the signal.
|
|
406
|
+
* @returns A read-only `Signal<boolean>` which is `true` if a dark theme
|
|
407
|
+
* is preferred, and `false` otherwise.
|
|
408
|
+
* @see {mediaQuery} for the underlying implementation.
|
|
409
|
+
*
|
|
410
|
+
* @example
|
|
411
|
+
* ```ts
|
|
412
|
+
* const isDarkMode = prefersDarkMode();
|
|
413
|
+
* effect(() => {
|
|
414
|
+
* document.body.classList.toggle('dark-theme', isDarkMode());
|
|
415
|
+
* });
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
function prefersDarkMode(debugName) {
|
|
419
|
+
return mediaQuery('(prefers-color-scheme: dark)', debugName);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
423
|
+
* for reduced motion using the `(prefers-reduced-motion: reduce)` media query.
|
|
424
|
+
*
|
|
425
|
+
* This is a convenience wrapper around the generic `mediaQuery` primitive.
|
|
426
|
+
* It's SSR-safe (defaults to `false` on the server) and automatically
|
|
427
|
+
* cleans up its event listeners.
|
|
428
|
+
*
|
|
429
|
+
* @param debugName Optional debug name for the signal.
|
|
430
|
+
* @returns A read-only `Signal<boolean>` which is `true` if reduced motion
|
|
431
|
+
* is preferred, and `false` otherwise.
|
|
432
|
+
* @see {mediaQuery} for the underlying implementation.
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* ```ts
|
|
436
|
+
* const reduceMotion = prefersReducedMotion();
|
|
437
|
+
* effect(() => {
|
|
438
|
+
* if (reduceMotion()) {
|
|
439
|
+
* // Apply simplified animations or disable them
|
|
440
|
+
* } else {
|
|
441
|
+
* // Apply full animations
|
|
442
|
+
* }
|
|
443
|
+
* });
|
|
444
|
+
* ```
|
|
445
|
+
*/
|
|
446
|
+
function prefersReducedMotion(debugName) {
|
|
447
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', debugName);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* A convenience function that creates and throttles a new `WritableSignal` in one step.
|
|
452
|
+
*
|
|
453
|
+
* @see {throttle} for the core implementation details.
|
|
454
|
+
*
|
|
455
|
+
* @template T The type of value the signal holds.
|
|
456
|
+
* @param initial The initial value of the signal.
|
|
457
|
+
* @param opt Options for signal creation, including throttle time `ms`.
|
|
458
|
+
* @returns A `ThrottledSignal<T>` instance.
|
|
459
|
+
*
|
|
460
|
+
* @example
|
|
461
|
+
* const query = throttled('', { ms: 500 });
|
|
462
|
+
* effect(() => console.log('Throttled Query:', query()));
|
|
463
|
+
*
|
|
464
|
+
* query.set('a');
|
|
465
|
+
* query.set('b');
|
|
466
|
+
* query.set('c');
|
|
467
|
+
* // With a trailing-edge throttle, the final value 'c' would be set
|
|
468
|
+
* // after the 500ms cooldown.
|
|
469
|
+
*/
|
|
470
|
+
function throttled(initial, opt) {
|
|
471
|
+
return throttle(signal(initial, opt), opt);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Wraps an existing `WritableSignal` to create a new one whose readable value is throttled.
|
|
475
|
+
*
|
|
476
|
+
* This implementation avoids using `effect` by pairing a trigger signal with an `untracked`
|
|
477
|
+
* read of the source signal to control when the throttled value is re-evaluated.
|
|
478
|
+
*
|
|
479
|
+
* @template T The type of value the signal holds.
|
|
480
|
+
* @param source The source `WritableSignal` to wrap. Writes are applied to this signal immediately.
|
|
481
|
+
* @param opt Options for throttling, including throttle time `ms` and an optional `DestroyRef`.
|
|
482
|
+
* @returns A new `ThrottledSignal<T>` whose read value is throttled. The `.original` property
|
|
483
|
+
* of the returned signal is a reference back to the provided `source` signal.
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* const query = throttled('', { ms: 500 });
|
|
487
|
+
* effect(() => console.log('Throttled Query:', query()));
|
|
488
|
+
*
|
|
489
|
+
* query.set('a');
|
|
490
|
+
* query.set('b');
|
|
491
|
+
* query.set('c');
|
|
492
|
+
* // With a trailing-edge throttle, the final value 'c' would be set
|
|
493
|
+
* // after the 500ms cooldown.
|
|
494
|
+
*/
|
|
495
|
+
function throttle(source, opt) {
|
|
496
|
+
const ms = opt?.ms ?? 0;
|
|
497
|
+
const trigger = signal(false);
|
|
498
|
+
let timeout;
|
|
499
|
+
try {
|
|
500
|
+
const destroyRef = opt?.destroyRef ?? inject(DestroyRef, { optional: true });
|
|
501
|
+
destroyRef?.onDestroy(() => {
|
|
502
|
+
if (timeout)
|
|
503
|
+
clearTimeout(timeout);
|
|
504
|
+
timeout = undefined;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
// not in injection context & no destroyRef provided opting out of cleanup
|
|
509
|
+
}
|
|
510
|
+
const triggerFn = (updateSourceAction) => {
|
|
511
|
+
updateSourceAction();
|
|
512
|
+
if (timeout)
|
|
513
|
+
return;
|
|
514
|
+
timeout = setTimeout(() => {
|
|
515
|
+
trigger.update((c) => !c);
|
|
516
|
+
timeout = undefined;
|
|
517
|
+
}, ms);
|
|
518
|
+
};
|
|
519
|
+
const set = (value) => {
|
|
520
|
+
triggerFn(() => source.set(value));
|
|
521
|
+
};
|
|
522
|
+
const update = (fn) => {
|
|
523
|
+
triggerFn(() => source.update(fn));
|
|
524
|
+
};
|
|
525
|
+
const writable = toWritable(computed(() => {
|
|
526
|
+
trigger();
|
|
527
|
+
return untracked(source);
|
|
528
|
+
}, opt), set, update);
|
|
529
|
+
writable.original = source;
|
|
530
|
+
return writable;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Creates a read-only signal that tracks the mouse cursor's position.
|
|
535
|
+
*
|
|
536
|
+
* It can track mouse movements on a specific target (window, document, or element)
|
|
537
|
+
* and optionally include touch movements. The coordinate space ('client' or 'page')
|
|
538
|
+
* can also be configured.
|
|
539
|
+
* The primitive is SSR-safe and automatically cleans up its event listeners.
|
|
540
|
+
*
|
|
541
|
+
* @param options Optional configuration for the sensor.
|
|
542
|
+
* @returns A read-only `Signal<MousePosition>`. On the server, it returns a static
|
|
543
|
+
* signal with `{ x: 0, y: 0 }`.
|
|
544
|
+
*
|
|
545
|
+
* @example
|
|
546
|
+
* ```ts
|
|
547
|
+
* import { Component, effect } from '@angular/core';
|
|
548
|
+
* import { mousePosition } from '@mmstack/primitives';
|
|
549
|
+
*
|
|
550
|
+
* @Component({
|
|
551
|
+
* selector: 'app-mouse-tracker',
|
|
552
|
+
* template: `<p>Mouse Position: X: {{ pos().x }}, Y: {{ pos().y }}</p>`
|
|
553
|
+
* })
|
|
554
|
+
* export class MouseTrackerComponent {
|
|
555
|
+
* readonly pos = mousePosition({ coordinateSpace: 'page' });
|
|
556
|
+
*
|
|
557
|
+
* constructor() {
|
|
558
|
+
* effect(() => {
|
|
559
|
+
* console.log('Mouse moved to:', this.pos());
|
|
560
|
+
* });
|
|
561
|
+
* }
|
|
562
|
+
* }
|
|
563
|
+
* ```
|
|
564
|
+
*/
|
|
565
|
+
function mousePosition(opt) {
|
|
566
|
+
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
567
|
+
const base = computed(() => ({
|
|
568
|
+
x: 0,
|
|
569
|
+
y: 0,
|
|
570
|
+
}), {
|
|
571
|
+
debugName: opt?.debugName,
|
|
572
|
+
});
|
|
573
|
+
base.unthrottled = base;
|
|
574
|
+
return base;
|
|
575
|
+
}
|
|
576
|
+
const { target = window, coordinateSpace = 'client', touch = false, debugName, throttle = 100, } = opt ?? {};
|
|
577
|
+
const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
|
|
578
|
+
if (!eventTarget) {
|
|
579
|
+
if (isDevMode())
|
|
580
|
+
console.warn('mousePosition: Target element not found.');
|
|
581
|
+
const base = computed(() => ({
|
|
582
|
+
x: 0,
|
|
583
|
+
y: 0,
|
|
584
|
+
}), {
|
|
585
|
+
debugName,
|
|
586
|
+
});
|
|
587
|
+
base.unthrottled = base;
|
|
588
|
+
return base;
|
|
589
|
+
}
|
|
590
|
+
const pos = throttled({ x: 0, y: 0 }, {
|
|
591
|
+
ms: throttle,
|
|
592
|
+
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
593
|
+
debugName,
|
|
594
|
+
});
|
|
595
|
+
const updatePosition = (event) => {
|
|
596
|
+
let x, y;
|
|
597
|
+
if (event instanceof MouseEvent) {
|
|
598
|
+
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
599
|
+
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
600
|
+
}
|
|
601
|
+
else if (event.touches.length > 0) {
|
|
602
|
+
const firstTouch = event.touches[0];
|
|
603
|
+
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
604
|
+
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
pos.set({ x, y });
|
|
610
|
+
};
|
|
611
|
+
eventTarget.addEventListener('mousemove', updatePosition);
|
|
612
|
+
if (touch) {
|
|
613
|
+
eventTarget.addEventListener('touchmove', updatePosition);
|
|
614
|
+
}
|
|
615
|
+
inject(DestroyRef).onDestroy(() => {
|
|
616
|
+
eventTarget.removeEventListener('mousemove', updatePosition);
|
|
617
|
+
if (touch) {
|
|
618
|
+
eventTarget.removeEventListener('touchmove', updatePosition);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
const base = pos.asReadonly();
|
|
622
|
+
base.unthrottled = pos.original;
|
|
623
|
+
return base;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const serverDate = new Date();
|
|
627
|
+
/**
|
|
628
|
+
* Creates a read-only signal that tracks the browser's online status.
|
|
629
|
+
*
|
|
630
|
+
* The main signal returns a boolean (`true` for online, `false` for offline).
|
|
631
|
+
* An additional `since` signal is attached, tracking when the status last changed.
|
|
632
|
+
* It's SSR-safe and automatically cleans up its event listeners.
|
|
633
|
+
*
|
|
634
|
+
* @param debugName Optional debug name for the signal.
|
|
635
|
+
* @returns A `NetworkStatusSignal` instance.
|
|
636
|
+
*/
|
|
637
|
+
function networkStatus(debugName) {
|
|
638
|
+
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
639
|
+
const sig = computed(() => true, {
|
|
640
|
+
debugName,
|
|
641
|
+
});
|
|
642
|
+
sig.since = computed(() => serverDate);
|
|
643
|
+
return sig;
|
|
644
|
+
}
|
|
645
|
+
const state = signal(navigator.onLine, {
|
|
646
|
+
debugName,
|
|
647
|
+
});
|
|
648
|
+
const since = signal(new Date());
|
|
649
|
+
const goOnline = () => {
|
|
650
|
+
state.set(true);
|
|
651
|
+
since.set(new Date());
|
|
652
|
+
};
|
|
653
|
+
const goOffline = () => {
|
|
654
|
+
state.set(false);
|
|
655
|
+
since.set(new Date());
|
|
656
|
+
};
|
|
657
|
+
window.addEventListener('online', goOnline);
|
|
658
|
+
window.addEventListener('offline', goOffline);
|
|
659
|
+
inject(DestroyRef).onDestroy(() => {
|
|
660
|
+
window.removeEventListener('online', goOnline);
|
|
661
|
+
window.removeEventListener('offline', goOffline);
|
|
662
|
+
});
|
|
663
|
+
const sig = state.asReadonly();
|
|
664
|
+
sig.since = since.asReadonly();
|
|
665
|
+
return sig;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Creates a read-only signal that tracks the page's visibility state.
|
|
670
|
+
*
|
|
671
|
+
* It uses the browser's Page Visibility API to reactively report if the
|
|
672
|
+
* current document is `'visible'`, `'hidden'`, or in another state.
|
|
673
|
+
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
674
|
+
* when the creating context is destroyed.
|
|
675
|
+
*
|
|
676
|
+
* @param debugName Optional debug name for the signal.
|
|
677
|
+
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
678
|
+
* it returns a static signal with a value of `'visible'`.
|
|
679
|
+
*
|
|
680
|
+
* @example
|
|
681
|
+
* ```ts
|
|
682
|
+
* import { Component, effect } from '@angular/core';
|
|
683
|
+
* import { pageVisibility } from '@mmstack/primitives';
|
|
684
|
+
*
|
|
685
|
+
* @Component({
|
|
686
|
+
* selector: 'app-visibility-tracker',
|
|
687
|
+
* template: `<p>Page is currently: {{ visibilityState() }}</p>`
|
|
688
|
+
* })
|
|
689
|
+
* export class VisibilityTrackerComponent {
|
|
690
|
+
* readonly visibilityState = pageVisibility();
|
|
691
|
+
*
|
|
692
|
+
* constructor() {
|
|
693
|
+
* effect(() => {
|
|
694
|
+
* if (this.visibilityState() === 'hidden') {
|
|
695
|
+
* console.log('Page is hidden, pausing expensive animations...');
|
|
696
|
+
* } else {
|
|
697
|
+
* console.log('Page is visible, resuming activity.');
|
|
698
|
+
* }
|
|
699
|
+
* });
|
|
700
|
+
* }
|
|
701
|
+
* }
|
|
702
|
+
* ```
|
|
703
|
+
*/
|
|
704
|
+
function pageVisibility(debugName) {
|
|
705
|
+
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
706
|
+
return computed(() => 'visible', { debugName });
|
|
707
|
+
}
|
|
708
|
+
const visibility = signal(document.visibilityState, { debugName });
|
|
709
|
+
const onVisibilityChange = () => visibility.set(document.visibilityState);
|
|
710
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
711
|
+
inject(DestroyRef).onDestroy(() => document.removeEventListener('visibilitychange', onVisibilityChange));
|
|
712
|
+
return visibility.asReadonly();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Implementation for sensor overloads.
|
|
717
|
+
* Users should refer to the specific overloads for detailed documentation.
|
|
718
|
+
* @internal
|
|
719
|
+
*/
|
|
720
|
+
function sensor(type, options) {
|
|
721
|
+
switch (type) {
|
|
722
|
+
case 'mousePosition':
|
|
723
|
+
return mousePosition(options);
|
|
724
|
+
case 'networkStatus':
|
|
725
|
+
return networkStatus(options?.debugName);
|
|
726
|
+
case 'pageVisibility':
|
|
727
|
+
return pageVisibility(options?.debugName);
|
|
728
|
+
case 'dark-mode':
|
|
729
|
+
return prefersDarkMode(options?.debugName);
|
|
730
|
+
case 'reduced-motion':
|
|
731
|
+
return prefersReducedMotion(options?.debugName);
|
|
732
|
+
default:
|
|
733
|
+
throw new Error(`Unknown sensor type: ${type}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
332
737
|
// Internal dummy store for server-side rendering
|
|
333
738
|
const noopStore = {
|
|
334
739
|
getItem: () => null,
|
|
@@ -390,7 +795,7 @@ const noopStore = {
|
|
|
390
795
|
* }
|
|
391
796
|
* ```
|
|
392
797
|
*/
|
|
393
|
-
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, ...rest }) {
|
|
798
|
+
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, ...rest }) {
|
|
394
799
|
const isServer = isPlatformServer(inject(PLATFORM_ID));
|
|
395
800
|
const fallbackStore = isServer ? noopStore : localStorage;
|
|
396
801
|
const store = providedStore ?? fallbackStore;
|
|
@@ -428,7 +833,8 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
428
833
|
...rest,
|
|
429
834
|
equal,
|
|
430
835
|
};
|
|
431
|
-
const
|
|
836
|
+
const initialKey = untracked(keySig);
|
|
837
|
+
const internal = signal(getValue(initialKey), {
|
|
432
838
|
...opt,
|
|
433
839
|
equal: (a, b) => {
|
|
434
840
|
if (a === null && b === null)
|
|
@@ -438,7 +844,33 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
438
844
|
return equal(a, b);
|
|
439
845
|
},
|
|
440
846
|
});
|
|
441
|
-
|
|
847
|
+
let prevKey = initialKey;
|
|
848
|
+
if (onKeyChange === 'store') {
|
|
849
|
+
effect(() => {
|
|
850
|
+
const k = keySig();
|
|
851
|
+
storeValue(k, internal());
|
|
852
|
+
if (prevKey !== k) {
|
|
853
|
+
if (cleanupOldKey)
|
|
854
|
+
store.removeItem(prevKey);
|
|
855
|
+
prevKey = k;
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
effect(() => {
|
|
861
|
+
const k = keySig();
|
|
862
|
+
if (k === prevKey) {
|
|
863
|
+
return storeValue(k, internal()); // normal operation
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
if (cleanupOldKey)
|
|
867
|
+
store.removeItem(prevKey);
|
|
868
|
+
const value = getValue(k);
|
|
869
|
+
internal.set(value); // load new value
|
|
870
|
+
prevKey = k;
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
}
|
|
442
874
|
if (syncTabs && !isServer) {
|
|
443
875
|
const destroyRef = inject(DestroyRef);
|
|
444
876
|
const sync = (e) => {
|
|
@@ -460,11 +892,160 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
460
892
|
return writable;
|
|
461
893
|
}
|
|
462
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Creates a Promise that resolves when a signal's value satisfies a given predicate.
|
|
897
|
+
*
|
|
898
|
+
* This is useful for imperatively waiting for a reactive state to change,
|
|
899
|
+
* for example, in tests or to orchestrate complex asynchronous operations.
|
|
900
|
+
*
|
|
901
|
+
* @template T The type of the signal's value.
|
|
902
|
+
* @param sourceSignal The signal to observe.
|
|
903
|
+
* @param predicate A function that takes the signal's value and returns `true` if the condition is met.
|
|
904
|
+
* @param options Optional configuration for timeout and explicit destruction.
|
|
905
|
+
* @returns A Promise that resolves with the signal's value when the predicate is true,
|
|
906
|
+
* or rejects on timeout or context destruction.
|
|
907
|
+
*
|
|
908
|
+
* @example
|
|
909
|
+
* ```ts
|
|
910
|
+
* const count = signal(0);
|
|
911
|
+
*
|
|
912
|
+
* async function waitForCount() {
|
|
913
|
+
* console.log('Waiting for count to be >= 3...');
|
|
914
|
+
* try {
|
|
915
|
+
* const finalCount = await until(count, c => c >= 3, { timeout: 5000 });
|
|
916
|
+
* console.log(`Count reached: ${finalCount}`);
|
|
917
|
+
* } catch (e: any) { // Ensure 'e' is typed if you access properties like e.message
|
|
918
|
+
* console.error(e.message); // e.g., "until: Timeout after 5000ms."
|
|
919
|
+
* }
|
|
920
|
+
* }
|
|
921
|
+
*
|
|
922
|
+
* // Simulate updates
|
|
923
|
+
* setTimeout(() => count.set(1), 500);
|
|
924
|
+
* setTimeout(() => count.set(2), 1000);
|
|
925
|
+
* setTimeout(() => count.set(3), 1500);
|
|
926
|
+
*
|
|
927
|
+
* waitForCount();
|
|
928
|
+
* ```
|
|
929
|
+
*/
|
|
930
|
+
function until(sourceSignal, predicate, options = {}) {
|
|
931
|
+
return new Promise((resolve, reject) => {
|
|
932
|
+
let effectRef;
|
|
933
|
+
let timeoutId;
|
|
934
|
+
let settled = false;
|
|
935
|
+
const cleanupAndReject = (reason) => {
|
|
936
|
+
if (!settled) {
|
|
937
|
+
settled = true;
|
|
938
|
+
if (timeoutId)
|
|
939
|
+
clearTimeout(timeoutId);
|
|
940
|
+
effectRef?.destroy();
|
|
941
|
+
reject(new Error(reason));
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
const cleanupAndResolve = (value) => {
|
|
945
|
+
if (!settled) {
|
|
946
|
+
settled = true;
|
|
947
|
+
if (timeoutId)
|
|
948
|
+
clearTimeout(timeoutId);
|
|
949
|
+
effectRef?.destroy();
|
|
950
|
+
resolve(value);
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
try {
|
|
954
|
+
const destroyRef = options.destroyRef ?? inject(DestroyRef, { optional: true });
|
|
955
|
+
destroyRef?.onDestroy(() => {
|
|
956
|
+
cleanupAndReject('until: Operation cancelled due to context destruction.');
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// noop
|
|
961
|
+
}
|
|
962
|
+
const initialValue = untracked(sourceSignal);
|
|
963
|
+
if (predicate(initialValue)) {
|
|
964
|
+
cleanupAndResolve(initialValue);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (options?.timeout !== undefined) {
|
|
968
|
+
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
969
|
+
}
|
|
970
|
+
effectRef = effect(() => {
|
|
971
|
+
if (settled) {
|
|
972
|
+
return effectRef?.destroy();
|
|
973
|
+
}
|
|
974
|
+
const currentValue = sourceSignal();
|
|
975
|
+
if (predicate(currentValue)) {
|
|
976
|
+
cleanupAndResolve(currentValue);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* @interal
|
|
984
|
+
*/
|
|
985
|
+
function getSignalEquality(sig) {
|
|
986
|
+
const internal = sig[SIGNAL];
|
|
987
|
+
if (internal && typeof internal.equal === 'function') {
|
|
988
|
+
return internal.equal;
|
|
989
|
+
}
|
|
990
|
+
return Object.is; // Default equality check
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
995
|
+
* stack and an API to control it.
|
|
996
|
+
*
|
|
997
|
+
* @template T The type of value held by the signal.
|
|
998
|
+
* @param source The source `WritableSignal` to add history tracking to.
|
|
999
|
+
* @param options Optional configuration for the history behavior.
|
|
1000
|
+
* @returns A `SignalWithHistory<T>` instance, augmenting the source signal with history APIs.
|
|
1001
|
+
*
|
|
1002
|
+
* @remarks
|
|
1003
|
+
* - Any new `.set()` or `.update()` call on the signal will clear the entire redo stack.
|
|
1004
|
+
* - The primitive attempts to automatically use the source signal's own `equal` function,
|
|
1005
|
+
* but this relies on an internal Angular API. For maximum stability across Angular
|
|
1006
|
+
* versions, it is recommended to provide an explicit `equal` function in the options.
|
|
1007
|
+
*
|
|
1008
|
+
* @example
|
|
1009
|
+
* ```ts
|
|
1010
|
+
* import { signal } from '@angular/core';
|
|
1011
|
+
* import { withHistory } from '@mmstack/primitives';
|
|
1012
|
+
*
|
|
1013
|
+
* const name = withHistory(signal('John'), { maxSize: 5 });
|
|
1014
|
+
*
|
|
1015
|
+
* console.log('Initial value:', name()); // "John"
|
|
1016
|
+
*
|
|
1017
|
+
* name.set('John Doe');
|
|
1018
|
+
* name.set('Jane Doe');
|
|
1019
|
+
*
|
|
1020
|
+
* console.log('Current value:', name()); // "Jane Doe"
|
|
1021
|
+
* console.log('History:', name.history()); // ["John", "John Doe"]
|
|
1022
|
+
* console.log('Can undo:', name.canUndo()); // true
|
|
1023
|
+
* console.log('Can redo:', name.canRedo()); // false
|
|
1024
|
+
*
|
|
1025
|
+
* name.undo();
|
|
1026
|
+
* console.log('After undo:', name()); // "John Doe"
|
|
1027
|
+
* console.log('Can redo:', name.canRedo()); // true
|
|
1028
|
+
*
|
|
1029
|
+
* name.redo();
|
|
1030
|
+
* console.log('After redo:', name()); // "Jane Doe"
|
|
1031
|
+
*
|
|
1032
|
+
* // A new change will clear the redo history
|
|
1033
|
+
* name.set('Janine Doe');
|
|
1034
|
+
* console.log('Can redo:', name.canRedo()); // false
|
|
1035
|
+
*
|
|
1036
|
+
* name.clear();
|
|
1037
|
+
* console.log('Can undo:', name.canUndo()); // false
|
|
1038
|
+
* ```
|
|
1039
|
+
*/
|
|
463
1040
|
function withHistory(source, opt) {
|
|
464
|
-
const
|
|
1041
|
+
const equal = opt?.equal ?? getSignalEquality(source);
|
|
465
1042
|
const maxSize = opt?.maxSize ?? Infinity;
|
|
466
|
-
const history = mutable([],
|
|
1043
|
+
const history = mutable([], {
|
|
1044
|
+
...opt,
|
|
1045
|
+
equal: undefined,
|
|
1046
|
+
});
|
|
467
1047
|
const redoArray = mutable([]);
|
|
1048
|
+
const originalSet = source.set;
|
|
468
1049
|
const set = (value) => {
|
|
469
1050
|
const current = untracked(source);
|
|
470
1051
|
if (equal(value, current))
|
|
@@ -472,7 +1053,12 @@ function withHistory(source, opt) {
|
|
|
472
1053
|
source.set(value);
|
|
473
1054
|
history.mutate((c) => {
|
|
474
1055
|
if (c.length >= maxSize) {
|
|
475
|
-
|
|
1056
|
+
if (opt?.cleanupStrategy === 'shift') {
|
|
1057
|
+
c.shift();
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
c = c.slice(Math.floor(maxSize / 2));
|
|
1061
|
+
}
|
|
476
1062
|
}
|
|
477
1063
|
c.push(current);
|
|
478
1064
|
return c;
|
|
@@ -484,26 +1070,39 @@ function withHistory(source, opt) {
|
|
|
484
1070
|
};
|
|
485
1071
|
const internal = toWritable(computed(() => source(), {
|
|
486
1072
|
equal,
|
|
487
|
-
debugName,
|
|
1073
|
+
debugName: opt?.debugName,
|
|
488
1074
|
}), set, update);
|
|
489
1075
|
internal.history = history;
|
|
490
1076
|
internal.undo = () => {
|
|
491
|
-
const
|
|
492
|
-
if (
|
|
1077
|
+
const historyStack = untracked(history);
|
|
1078
|
+
if (historyStack.length === 0)
|
|
493
1079
|
return;
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1080
|
+
const valueForRedo = untracked(source);
|
|
1081
|
+
const valueToRestore = historyStack.at(-1);
|
|
1082
|
+
originalSet.call(source, valueToRestore);
|
|
1083
|
+
history.inline((h) => h.pop());
|
|
1084
|
+
redoArray.inline((r) => r.push(valueForRedo));
|
|
499
1085
|
};
|
|
500
1086
|
internal.redo = () => {
|
|
501
|
-
const
|
|
502
|
-
if (
|
|
1087
|
+
const redoStack = untracked(redoArray);
|
|
1088
|
+
if (redoStack.length === 0)
|
|
503
1089
|
return;
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
1090
|
+
const valueForUndo = untracked(source);
|
|
1091
|
+
const valueToRestore = redoStack.at(-1);
|
|
1092
|
+
originalSet.call(source, valueToRestore);
|
|
1093
|
+
redoArray.inline((r) => r.pop());
|
|
1094
|
+
history.mutate((h) => {
|
|
1095
|
+
if (h.length >= maxSize) {
|
|
1096
|
+
if (opt?.cleanupStrategy === 'shift') {
|
|
1097
|
+
h.shift();
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
h = h.slice(Math.floor(maxSize / 2));
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
h.push(valueForUndo);
|
|
1104
|
+
return h;
|
|
1105
|
+
});
|
|
507
1106
|
};
|
|
508
1107
|
internal.clear = () => {
|
|
509
1108
|
history.set([]);
|
|
@@ -519,5 +1118,5 @@ function withHistory(source, opt) {
|
|
|
519
1118
|
* Generated bundle index. Do not edit.
|
|
520
1119
|
*/
|
|
521
1120
|
|
|
522
|
-
export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mutable, stored, toFakeDerivation, toFakeSignalDerivation, toWritable, withHistory };
|
|
1121
|
+
export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, prefersDarkMode, prefersReducedMotion, sensor, stored, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, withHistory };
|
|
523
1122
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|