@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.
@@ -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) => cur.map((v, i) => i === optOrKey ? next : v));
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 internal = signal(getValue(untracked(keySig)), {
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
- effect(() => storeValue(keySig(), internal()));
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 { equal = Object.is, debugName } = source[SIGNAL];
1041
+ const equal = opt?.equal ?? getSignalEquality(source);
465
1042
  const maxSize = opt?.maxSize ?? Infinity;
466
- const history = mutable([], opt);
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
- c = c.slice(Math.floor(maxSize / 2));
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 last = untracked(history);
492
- if (last.length === 0)
1077
+ const historyStack = untracked(history);
1078
+ if (historyStack.length === 0)
493
1079
  return;
494
- const prev = last.at(-1);
495
- const cur = untracked(source);
496
- history.inline((c) => c.pop());
497
- redoArray.inline((c) => c.push(cur));
498
- source.set(prev);
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 last = untracked(redoArray);
502
- if (last.length === 0)
1087
+ const redoStack = untracked(redoArray);
1088
+ if (redoStack.length === 0)
503
1089
  return;
504
- const prev = last.at(-1);
505
- redoArray.inline((c) => c.pop());
506
- set(prev);
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