@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
|
@@ -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, Injector, runInInjectionContext } from '@angular/core';
|
|
2
2
|
import { isPlatformServer } from '@angular/common';
|
|
3
3
|
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
4
4
|
|
|
@@ -336,6 +336,404 @@ function isMutable(value) {
|
|
|
336
336
|
return 'mutate' in value && typeof value.mutate === 'function';
|
|
337
337
|
}
|
|
338
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
|
+
|
|
339
737
|
// Internal dummy store for server-side rendering
|
|
340
738
|
const noopStore = {
|
|
341
739
|
getItem: () => null,
|
|
@@ -494,11 +892,163 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
494
892
|
return writable;
|
|
495
893
|
}
|
|
496
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
|
+
const injector = options.injector ?? inject(Injector);
|
|
932
|
+
return new Promise((resolve, reject) => {
|
|
933
|
+
let effectRef;
|
|
934
|
+
let timeoutId;
|
|
935
|
+
let settled = false;
|
|
936
|
+
const cleanupAndReject = (reason) => {
|
|
937
|
+
if (!settled) {
|
|
938
|
+
settled = true;
|
|
939
|
+
if (timeoutId)
|
|
940
|
+
clearTimeout(timeoutId);
|
|
941
|
+
effectRef?.destroy();
|
|
942
|
+
reject(new Error(reason));
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
const cleanupAndResolve = (value) => {
|
|
946
|
+
if (!settled) {
|
|
947
|
+
settled = true;
|
|
948
|
+
if (timeoutId)
|
|
949
|
+
clearTimeout(timeoutId);
|
|
950
|
+
effectRef?.destroy();
|
|
951
|
+
resolve(value);
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
try {
|
|
955
|
+
const destroyRef = options.destroyRef ?? inject(DestroyRef, { optional: true });
|
|
956
|
+
destroyRef?.onDestroy(() => {
|
|
957
|
+
cleanupAndReject('until: Operation cancelled due to context destruction.');
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
// noop
|
|
962
|
+
}
|
|
963
|
+
const initialValue = untracked(sourceSignal);
|
|
964
|
+
if (predicate(initialValue)) {
|
|
965
|
+
cleanupAndResolve(initialValue);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (options?.timeout !== undefined) {
|
|
969
|
+
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
970
|
+
}
|
|
971
|
+
runInInjectionContext(injector, () => {
|
|
972
|
+
effectRef = effect(() => {
|
|
973
|
+
if (settled) {
|
|
974
|
+
return effectRef?.destroy();
|
|
975
|
+
}
|
|
976
|
+
const currentValue = sourceSignal();
|
|
977
|
+
if (predicate(currentValue)) {
|
|
978
|
+
cleanupAndResolve(currentValue);
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* @interal
|
|
987
|
+
*/
|
|
988
|
+
function getSignalEquality(sig) {
|
|
989
|
+
const internal = sig[SIGNAL];
|
|
990
|
+
if (internal && typeof internal.equal === 'function') {
|
|
991
|
+
return internal.equal;
|
|
992
|
+
}
|
|
993
|
+
return Object.is; // Default equality check
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
998
|
+
* stack and an API to control it.
|
|
999
|
+
*
|
|
1000
|
+
* @template T The type of value held by the signal.
|
|
1001
|
+
* @param source The source `WritableSignal` to add history tracking to.
|
|
1002
|
+
* @param options Optional configuration for the history behavior.
|
|
1003
|
+
* @returns A `SignalWithHistory<T>` instance, augmenting the source signal with history APIs.
|
|
1004
|
+
*
|
|
1005
|
+
* @remarks
|
|
1006
|
+
* - Any new `.set()` or `.update()` call on the signal will clear the entire redo stack.
|
|
1007
|
+
* - The primitive attempts to automatically use the source signal's own `equal` function,
|
|
1008
|
+
* but this relies on an internal Angular API. For maximum stability across Angular
|
|
1009
|
+
* versions, it is recommended to provide an explicit `equal` function in the options.
|
|
1010
|
+
*
|
|
1011
|
+
* @example
|
|
1012
|
+
* ```ts
|
|
1013
|
+
* import { signal } from '@angular/core';
|
|
1014
|
+
* import { withHistory } from '@mmstack/primitives';
|
|
1015
|
+
*
|
|
1016
|
+
* const name = withHistory(signal('John'), { maxSize: 5 });
|
|
1017
|
+
*
|
|
1018
|
+
* console.log('Initial value:', name()); // "John"
|
|
1019
|
+
*
|
|
1020
|
+
* name.set('John Doe');
|
|
1021
|
+
* name.set('Jane Doe');
|
|
1022
|
+
*
|
|
1023
|
+
* console.log('Current value:', name()); // "Jane Doe"
|
|
1024
|
+
* console.log('History:', name.history()); // ["John", "John Doe"]
|
|
1025
|
+
* console.log('Can undo:', name.canUndo()); // true
|
|
1026
|
+
* console.log('Can redo:', name.canRedo()); // false
|
|
1027
|
+
*
|
|
1028
|
+
* name.undo();
|
|
1029
|
+
* console.log('After undo:', name()); // "John Doe"
|
|
1030
|
+
* console.log('Can redo:', name.canRedo()); // true
|
|
1031
|
+
*
|
|
1032
|
+
* name.redo();
|
|
1033
|
+
* console.log('After redo:', name()); // "Jane Doe"
|
|
1034
|
+
*
|
|
1035
|
+
* // A new change will clear the redo history
|
|
1036
|
+
* name.set('Janine Doe');
|
|
1037
|
+
* console.log('Can redo:', name.canRedo()); // false
|
|
1038
|
+
*
|
|
1039
|
+
* name.clear();
|
|
1040
|
+
* console.log('Can undo:', name.canUndo()); // false
|
|
1041
|
+
* ```
|
|
1042
|
+
*/
|
|
497
1043
|
function withHistory(source, opt) {
|
|
498
|
-
const
|
|
1044
|
+
const equal = opt?.equal ?? getSignalEquality(source);
|
|
499
1045
|
const maxSize = opt?.maxSize ?? Infinity;
|
|
500
|
-
const history = mutable([],
|
|
1046
|
+
const history = mutable([], {
|
|
1047
|
+
...opt,
|
|
1048
|
+
equal: undefined,
|
|
1049
|
+
});
|
|
501
1050
|
const redoArray = mutable([]);
|
|
1051
|
+
const originalSet = source.set;
|
|
502
1052
|
const set = (value) => {
|
|
503
1053
|
const current = untracked(source);
|
|
504
1054
|
if (equal(value, current))
|
|
@@ -506,7 +1056,12 @@ function withHistory(source, opt) {
|
|
|
506
1056
|
source.set(value);
|
|
507
1057
|
history.mutate((c) => {
|
|
508
1058
|
if (c.length >= maxSize) {
|
|
509
|
-
|
|
1059
|
+
if (opt?.cleanupStrategy === 'shift') {
|
|
1060
|
+
c.shift();
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
c = c.slice(Math.floor(maxSize / 2));
|
|
1064
|
+
}
|
|
510
1065
|
}
|
|
511
1066
|
c.push(current);
|
|
512
1067
|
return c;
|
|
@@ -518,26 +1073,39 @@ function withHistory(source, opt) {
|
|
|
518
1073
|
};
|
|
519
1074
|
const internal = toWritable(computed(() => source(), {
|
|
520
1075
|
equal,
|
|
521
|
-
debugName,
|
|
1076
|
+
debugName: opt?.debugName,
|
|
522
1077
|
}), set, update);
|
|
523
1078
|
internal.history = history;
|
|
524
1079
|
internal.undo = () => {
|
|
525
|
-
const
|
|
526
|
-
if (
|
|
1080
|
+
const historyStack = untracked(history);
|
|
1081
|
+
if (historyStack.length === 0)
|
|
527
1082
|
return;
|
|
528
|
-
const
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1083
|
+
const valueForRedo = untracked(source);
|
|
1084
|
+
const valueToRestore = historyStack.at(-1);
|
|
1085
|
+
originalSet.call(source, valueToRestore);
|
|
1086
|
+
history.inline((h) => h.pop());
|
|
1087
|
+
redoArray.inline((r) => r.push(valueForRedo));
|
|
533
1088
|
};
|
|
534
1089
|
internal.redo = () => {
|
|
535
|
-
const
|
|
536
|
-
if (
|
|
1090
|
+
const redoStack = untracked(redoArray);
|
|
1091
|
+
if (redoStack.length === 0)
|
|
537
1092
|
return;
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
1093
|
+
const valueForUndo = untracked(source);
|
|
1094
|
+
const valueToRestore = redoStack.at(-1);
|
|
1095
|
+
originalSet.call(source, valueToRestore);
|
|
1096
|
+
redoArray.inline((r) => r.pop());
|
|
1097
|
+
history.mutate((h) => {
|
|
1098
|
+
if (h.length >= maxSize) {
|
|
1099
|
+
if (opt?.cleanupStrategy === 'shift') {
|
|
1100
|
+
h.shift();
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
h = h.slice(Math.floor(maxSize / 2));
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
h.push(valueForUndo);
|
|
1107
|
+
return h;
|
|
1108
|
+
});
|
|
541
1109
|
};
|
|
542
1110
|
internal.clear = () => {
|
|
543
1111
|
history.set([]);
|
|
@@ -553,5 +1121,5 @@ function withHistory(source, opt) {
|
|
|
553
1121
|
* Generated bundle index. Do not edit.
|
|
554
1122
|
*/
|
|
555
1123
|
|
|
556
|
-
export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mutable, stored, toFakeDerivation, toFakeSignalDerivation, toWritable, withHistory };
|
|
1124
|
+
export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, prefersDarkMode, prefersReducedMotion, sensor, stored, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, withHistory };
|
|
557
1125
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|