@mmstack/primitives 19.3.5 → 19.3.7

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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, computed, signal, isSignal, ElementRef, PLATFORM_ID, Injectable, runInInjectionContext } from '@angular/core';
2
+ import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, computed, signal, isSignal, PLATFORM_ID, ElementRef, Injectable, runInInjectionContext } from '@angular/core';
3
3
  import { isPlatformServer } from '@angular/common';
4
4
  import { SIGNAL } from '@angular/core/primitives/signals';
5
5
 
@@ -351,9 +351,9 @@ function debounce(source, opt) {
351
351
  const { is } = Object;
352
352
  function mutable(initial, opt) {
353
353
  const baseEqual = opt?.equal ?? is;
354
- let trigger = false;
354
+ let cnt = 0;
355
355
  const equal = (a, b) => {
356
- if (trigger)
356
+ if (cnt > 0)
357
357
  return false;
358
358
  return baseEqual(a, b);
359
359
  };
@@ -363,9 +363,9 @@ function mutable(initial, opt) {
363
363
  });
364
364
  const internalUpdate = sig.update;
365
365
  sig.mutate = (updater) => {
366
- trigger = true;
366
+ cnt++;
367
367
  internalUpdate(updater);
368
- trigger = false;
368
+ cnt--;
369
369
  };
370
370
  sig.inline = (updater) => {
371
371
  sig.mutate((prev) => {
@@ -432,10 +432,10 @@ function derived(source, optOrKey, opt) {
432
432
  };
433
433
  const rest = typeof optOrKey === 'object' ? { ...optOrKey, ...opt } : opt;
434
434
  const baseEqual = rest?.equal ?? Object.is;
435
- let trigger = false;
435
+ let cnt = 0;
436
436
  const equal = isMutable(source)
437
437
  ? (a, b) => {
438
- if (trigger)
438
+ if (cnt > 0)
439
439
  return false;
440
440
  return baseEqual(a, b);
441
441
  }
@@ -444,9 +444,9 @@ function derived(source, optOrKey, opt) {
444
444
  sig.from = from;
445
445
  if (isMutable(source)) {
446
446
  sig.mutate = (updater) => {
447
- trigger = true;
447
+ cnt++;
448
448
  sig.update(updater);
449
- trigger = false;
449
+ cnt--;
450
450
  };
451
451
  sig.inline = (updater) => {
452
452
  sig.mutate((prev) => {
@@ -551,9 +551,11 @@ function indexArray(source, map, opt = {}) {
551
551
  });
552
552
  if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
553
553
  opt.equal = (a, b) => {
554
- if (a !== b)
555
- return false; // actually check primitives and references
556
- return false; // opt out for same refs
554
+ if (typeof a !== typeof b)
555
+ return false;
556
+ if (typeof a === 'object' || typeof a === 'function')
557
+ return false;
558
+ return a === b;
557
559
  };
558
560
  }
559
561
  return linkedSignal({
@@ -600,7 +602,30 @@ const mapArray = indexArray;
600
602
  * @param mapFn The mapping function. Receives the item and its index as a Signal.
601
603
  * @param options Optional configuration:
602
604
  * - `onDestroy`: A callback invoked when a mapped item is removed from the array.
605
+ * - `key`: A custom key extractor for identity matching (e.g. `(item) => item.id`)
606
+ * when item references change but conceptual identity is preserved.
603
607
  * @returns A `Signal<U[]>` containing the mapped array.
608
+ *
609
+ * @example
610
+ * ```ts
611
+ * const users = signal([
612
+ * { id: 1, name: 'Alice' },
613
+ * { id: 2, name: 'Bob' },
614
+ * ]);
615
+ *
616
+ * const rows = keyArray(
617
+ * users,
618
+ * (user, index) => ({
619
+ * label: computed(() => `#${index()} ${user.name}`),
620
+ * id: user.id,
621
+ * }),
622
+ * { key: (u) => u.id },
623
+ * );
624
+ *
625
+ * // Reordering users() rebuilds index signals only — `rows` entries
626
+ * // are matched by id and reused, not re-created.
627
+ * users.set([users()[1], users()[0]]);
628
+ * ```
604
629
  */
605
630
  function keyArray(source, mapFn, options = {}) {
606
631
  const sourceSignal = isSignal(source) ? source : computed(source);
@@ -757,15 +782,69 @@ function mapObject(source, mapFn, options = {}) {
757
782
  }).asReadonly();
758
783
  }
759
784
 
760
- /** Project with optional equality. Pure & sync. */
785
+ /**
786
+ * Synchronous projection of a signal value with optional `CreateSignalOptions`
787
+ * (custom `equal`, `debugName`, etc.). Equivalent to `map` plus the ability to
788
+ * pass signal options through to the underlying `computed()`.
789
+ *
790
+ * @example
791
+ * ```ts
792
+ * const user = piped({ id: 1, name: 'Alice' });
793
+ * const name = user.pipe(select((u) => u.name));
794
+ * name(); // 'Alice'
795
+ * ```
796
+ */
761
797
  const select = (projector, opt) => (src) => computed(() => projector(src()), opt);
762
- /** Combine with another signal using a projector. */
798
+ /**
799
+ * Combine the piped signal with another `Signal` using a projector. The result
800
+ * recomputes whenever either source changes.
801
+ *
802
+ * @example
803
+ * ```ts
804
+ * const price = piped(10);
805
+ * const quantity = signal(3);
806
+ * const total = price.pipe(combineWith(quantity, (p, q) => p * q));
807
+ * total(); // 30
808
+ * ```
809
+ */
763
810
  const combineWith = (other, project, opt) => (src) => computed(() => project(src(), other()), opt);
764
- /** Only re-emit when equal(prev, next) is false. */
811
+ /**
812
+ * Suppress emissions while consecutive values are considered equal. The
813
+ * comparator defaults to `Object.is`; pass a custom one for structural or
814
+ * key-based equality (e.g. compare by `id` only).
815
+ *
816
+ * @example
817
+ * ```ts
818
+ * const user = piped({ id: 1, lastSeen: Date.now() });
819
+ * const byId = user.pipe(distinct((a, b) => a.id === b.id));
820
+ * // byId only re-emits when `id` changes, not on every `lastSeen` update
821
+ * ```
822
+ */
765
823
  const distinct = (equal = Object.is) => (src) => computed(() => src(), { equal });
766
- /** map to new value */
824
+ /**
825
+ * Pure synchronous transform from input to output. Equivalent to a `computed()`
826
+ * that reads the source and returns `fn(value)`.
827
+ *
828
+ * @example
829
+ * ```ts
830
+ * const count = piped(2);
831
+ * const doubled = count.pipe(map((n) => n * 2));
832
+ * doubled(); // 4
833
+ * ```
834
+ */
767
835
  const map = (fn) => (src) => computed(() => fn(src()));
768
- /** filter values, keeping the last value if it was ever available, if first value is filtered will return undefined */
836
+ /**
837
+ * Keep only values that pass the predicate. The result holds the last passing
838
+ * value across emissions; before any value passes, the result is `undefined` —
839
+ * see {@link filterWith} when you need a non-`undefined` seed.
840
+ *
841
+ * @example
842
+ * ```ts
843
+ * const event = piped<MouseEvent | null>(null);
844
+ * const clicks = event.pipe(filter((e): e is MouseEvent => e?.type === 'click'));
845
+ * clicks(); // undefined until a click happens, then the last MouseEvent
846
+ * ```
847
+ */
769
848
  const filter = (predicate) => (src) => linkedSignal({
770
849
  source: src,
771
850
  computation: (next, prev) => {
@@ -774,11 +853,90 @@ const filter = (predicate) => (src) => linkedSignal({
774
853
  return prev?.source;
775
854
  },
776
855
  });
777
- /** tap into the value */
778
- const tap = (fn) => (src) => {
779
- effect(() => fn(src()));
856
+ /**
857
+ * Run a side effect on every emission without altering the signal value. Wraps
858
+ * Angular's `effect()`, so it must run in an injection context or receive an
859
+ * explicit `injector`. Use for logging / analytics — not for setting other
860
+ * signals (that's what regular `effect()` is for).
861
+ *
862
+ * @example
863
+ * ```ts
864
+ * const count = piped(0);
865
+ * count.pipe(tap((n) => console.log('count:', n)));
866
+ * count.set(1); // logs 'count: 1'
867
+ * ```
868
+ */
869
+ const tap = (fn, injector) => (src) => {
870
+ effect(() => fn(src()), {
871
+ injector,
872
+ });
780
873
  return src;
781
874
  };
875
+ /**
876
+ * Like {@link filter}, but emits `initial` until a value first passes the
877
+ * predicate. Eliminates the `T | undefined` return type at the cost of an
878
+ * explicit seed value.
879
+ *
880
+ * @example
881
+ * ```ts
882
+ * const event = piped<MouseEvent | null>(null);
883
+ * const lastClick = event.pipe(filterWith((e) => e?.type === 'click', null));
884
+ * lastClick(); // null until the first click, then the most recent click event
885
+ * ```
886
+ */
887
+ const filterWith = (predicate, initial) => (src) => linkedSignal({
888
+ source: src,
889
+ computation: (next, prev) => predicate(next) ? next : (prev?.value ?? initial),
890
+ });
891
+ /**
892
+ * Emit `initial` on the first read, then mirror the source on every subsequent
893
+ * read. Useful for giving a pipeline a sensible seed value before the source
894
+ * is ready (e.g. loading state).
895
+ *
896
+ * @example
897
+ * ```ts
898
+ * const data = piped<User | null>(null);
899
+ * const view = data.pipe(startWith<User | null, 'loading'>('loading'));
900
+ * view(); // 'loading' on first read, then User | null afterward
901
+ * ```
902
+ */
903
+ const startWith = (initial) => (src) => linkedSignal({
904
+ source: src,
905
+ computation: (next, prev) => (prev === undefined ? initial : next),
906
+ });
907
+ /**
908
+ * Emit `[prev, curr]` tuples so consumers can react to transitions instead of
909
+ * raw values. On the first emission `prev` is `undefined`.
910
+ *
911
+ * @example
912
+ * ```ts
913
+ * const count = piped(0);
914
+ * const delta = count.pipe(pairwise(), map(([prev, curr]) => curr - (prev ?? 0)));
915
+ * count.set(5);
916
+ * delta(); // 5
917
+ * ```
918
+ */
919
+ const pairwise = () => (src) => linkedSignal({
920
+ source: src,
921
+ computation: (next, prev) => [prev?.source, next],
922
+ });
923
+ /**
924
+ * Reduce-like accumulator that folds each emission into a running result.
925
+ * Behaves like `Array.prototype.reduce` but applied over time, with the
926
+ * accumulator persisted across emissions.
927
+ *
928
+ * @example
929
+ * ```ts
930
+ * const delta = piped(0);
931
+ * const total = delta.pipe(scan((acc, n) => acc + n, 0));
932
+ * delta.set(5); // total() === 5
933
+ * delta.set(3); // total() === 8
934
+ * ```
935
+ */
936
+ const scan = (reducer, seed) => (src) => linkedSignal({
937
+ source: src,
938
+ computation: (next, prev) => reducer(prev?.value ?? seed, next),
939
+ });
782
940
 
783
941
  /**
784
942
  * Decorate any `Signal<T>` with a chainable `.pipe(...)` method.
@@ -882,12 +1040,10 @@ function pooled({ create, reset, computation, ...opt }) {
882
1040
  const next = other ?? untracked(() => create());
883
1041
  if (current !== undefined) {
884
1042
  if (currentFresh) {
885
- // never-mutated buffer leaving the active slot; nothing to clean
886
1043
  other = current;
887
1044
  }
888
1045
  else {
889
- // reset on release: clean the dirty buffer as it goes back into the pool
890
- // (also threads the swap-return correctly into the pool's spare slot)
1046
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by the `current !== undefined` check above
891
1047
  other = untracked(() => reset(current)) ?? current;
892
1048
  }
893
1049
  }
@@ -934,6 +1090,96 @@ function pooledMap(optOrComputation, signalOpt) {
934
1090
  return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
935
1091
  }
936
1092
 
1093
+ const EVENTS = [
1094
+ 'chargingchange',
1095
+ 'levelchange',
1096
+ 'chargingtimechange',
1097
+ 'dischargingtimechange',
1098
+ ];
1099
+ /**
1100
+ * Creates a read-only signal that tracks the system battery status using the
1101
+ * Battery Status API. Returns `null` until the underlying `getBattery()`
1102
+ * promise resolves, or permanently when the API is unsupported (Firefox /
1103
+ * Safari at the time of writing). SSR-safe.
1104
+ *
1105
+ * @example
1106
+ * ```ts
1107
+ * const battery = batteryStatus();
1108
+ * effect(() => {
1109
+ * const b = battery();
1110
+ * if (b) console.log(`${Math.round(b.level * 100)}% • charging: ${b.charging}`);
1111
+ * });
1112
+ * ```
1113
+ */
1114
+ function batteryStatus(debugName = 'batteryStatus') {
1115
+ if (isPlatformServer(inject(PLATFORM_ID)) ||
1116
+ typeof navigator === 'undefined' ||
1117
+ typeof navigator.getBattery !== 'function') {
1118
+ return computed(() => null, { debugName });
1119
+ }
1120
+ const state = signal(null, { debugName });
1121
+ const abortController = new AbortController();
1122
+ inject(DestroyRef).onDestroy(() => abortController.abort());
1123
+ navigator.getBattery().then((battery) => {
1124
+ if (abortController.signal.aborted)
1125
+ return;
1126
+ const read = () => ({
1127
+ level: battery.level,
1128
+ charging: battery.charging,
1129
+ chargingTime: battery.chargingTime,
1130
+ dischargingTime: battery.dischargingTime,
1131
+ });
1132
+ const onChange = () => state.set(read());
1133
+ state.set(read());
1134
+ for (const ev of EVENTS) {
1135
+ battery.addEventListener(ev, onChange, {
1136
+ signal: abortController.signal,
1137
+ });
1138
+ }
1139
+ });
1140
+ return state.asReadonly();
1141
+ }
1142
+
1143
+ /**
1144
+ * Creates a read-only signal mirroring the system clipboard contents.
1145
+ *
1146
+ * The signal value starts empty and updates whenever a `copy` event fires on
1147
+ * the document (or {@link ClipboardSignal.copy} is invoked from this app).
1148
+ * SSR-safe — returns `''` and `isSupported: false` on the server.
1149
+ *
1150
+ * Note: read access requires the Clipboard API and an active permission grant
1151
+ * in browsers that gate it. Errors from `navigator.clipboard.readText` are
1152
+ * swallowed silently to keep the signal value stable.
1153
+ */
1154
+ function clipboard(debugName = 'clipboard') {
1155
+ if (isPlatformServer(inject(PLATFORM_ID)) ||
1156
+ typeof navigator === 'undefined' ||
1157
+ !navigator.clipboard) {
1158
+ const sig = computed(() => '', { debugName });
1159
+ sig.copy = () => Promise.resolve();
1160
+ sig.isSupported = computed(() => false);
1161
+ return sig;
1162
+ }
1163
+ const state = signal('', { debugName });
1164
+ const refresh = () => {
1165
+ navigator.clipboard.readText().then((value) => state.set(value), () => {
1166
+ // permission denied / focus required — ignore
1167
+ });
1168
+ };
1169
+ const abortController = new AbortController();
1170
+ const onCopy = () => refresh();
1171
+ document.addEventListener('copy', onCopy, { signal: abortController.signal });
1172
+ document.addEventListener('cut', onCopy, { signal: abortController.signal });
1173
+ inject(DestroyRef).onDestroy(() => abortController.abort());
1174
+ const sig = state.asReadonly();
1175
+ sig.copy = async (value) => {
1176
+ await navigator.clipboard.writeText(value);
1177
+ state.set(value);
1178
+ };
1179
+ sig.isSupported = computed(() => true);
1180
+ return sig;
1181
+ }
1182
+
937
1183
  function observerSupported$1() {
938
1184
  return typeof ResizeObserver !== 'undefined';
939
1185
  }
@@ -1134,6 +1380,184 @@ function elementVisibility(target = inject(ElementRef), opt) {
1134
1380
  return base;
1135
1381
  }
1136
1382
 
1383
+ function unwrap$1(target) {
1384
+ if (!target)
1385
+ return null;
1386
+ return target instanceof ElementRef ? target.nativeElement : target;
1387
+ }
1388
+ /**
1389
+ * Creates a read-only signal that tracks whether the focused element is the
1390
+ * target or a descendant of it. Mirrors the CSS `:focus-within` pseudo-class.
1391
+ *
1392
+ * Defaults `target` to the current `ElementRef` so it can be used inline in a
1393
+ * component's `class` field. SSR-safe — returns a constant `false` signal on
1394
+ * the server.
1395
+ *
1396
+ * @example
1397
+ * ```ts
1398
+ * @Component({ ... })
1399
+ * class MenuComponent {
1400
+ * // Defaults to the host element — flips true when focus is inside.
1401
+ * readonly hasFocus = focusWithin();
1402
+ * }
1403
+ * ```
1404
+ */
1405
+ function focusWithin(target = inject(ElementRef)) {
1406
+ if (isPlatformServer(inject(PLATFORM_ID))) {
1407
+ return computed(() => false, { debugName: 'focusWithin' });
1408
+ }
1409
+ const state = signal(false, { debugName: 'focusWithin' });
1410
+ const attach = (el) => {
1411
+ state.set(el.contains(document.activeElement));
1412
+ const abortController = new AbortController();
1413
+ el.addEventListener('focusin', () => state.set(true), {
1414
+ signal: abortController.signal,
1415
+ });
1416
+ el.addEventListener('focusout', () => {
1417
+ // Defer so `document.activeElement` reflects the focus move.
1418
+ queueMicrotask(() => state.set(el.contains(document.activeElement)));
1419
+ }, { signal: abortController.signal });
1420
+ return () => abortController.abort();
1421
+ };
1422
+ if (isSignal(target)) {
1423
+ const targetSig = target;
1424
+ effect((cleanup) => {
1425
+ const el = unwrap$1(targetSig());
1426
+ if (!el) {
1427
+ state.set(false);
1428
+ return;
1429
+ }
1430
+ cleanup(attach(el));
1431
+ });
1432
+ }
1433
+ else {
1434
+ const el = unwrap$1(target);
1435
+ if (el) {
1436
+ const detach = attach(el);
1437
+ inject(DestroyRef).onDestroy(detach);
1438
+ }
1439
+ }
1440
+ return state.asReadonly();
1441
+ }
1442
+
1443
+ /**
1444
+ * Creates a read-only signal that exposes the current geolocation position.
1445
+ *
1446
+ * The returned signal carries `error` and `loading` sub-signals for permission
1447
+ * failures and the in-flight initial fetch respectively. SSR-safe — on the
1448
+ * server the position is `null`, loading is `false`, and no API calls are made.
1449
+ *
1450
+ * @example
1451
+ * ```ts
1452
+ * const where = geolocation({ watch: true, enableHighAccuracy: true });
1453
+ * effect(() => console.log(where()?.coords, where.error()));
1454
+ * ```
1455
+ */
1456
+ function geolocation(opt) {
1457
+ if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
1458
+ const sig = computed(() => null, {
1459
+ debugName: opt?.debugName ?? 'geolocation',
1460
+ });
1461
+ sig.error = computed(() => null);
1462
+ sig.loading = computed(() => false);
1463
+ return sig;
1464
+ }
1465
+ const position = signal(null, {
1466
+ debugName: opt?.debugName ?? 'geolocation',
1467
+ });
1468
+ const error = signal(null);
1469
+ const loading = signal(true);
1470
+ const onSuccess = (p) => {
1471
+ position.set(p);
1472
+ error.set(null);
1473
+ loading.set(false);
1474
+ };
1475
+ const onError = (e) => {
1476
+ error.set(e);
1477
+ loading.set(false);
1478
+ };
1479
+ if (opt?.watch) {
1480
+ const watchId = navigator.geolocation.watchPosition(onSuccess, onError, opt);
1481
+ inject(DestroyRef).onDestroy(() => navigator.geolocation.clearWatch(watchId));
1482
+ }
1483
+ else {
1484
+ navigator.geolocation.getCurrentPosition(onSuccess, onError, opt);
1485
+ }
1486
+ const sig = position.asReadonly();
1487
+ sig.error = error.asReadonly();
1488
+ sig.loading = loading.asReadonly();
1489
+ return sig;
1490
+ }
1491
+
1492
+ const DEFAULT_EVENTS = [
1493
+ 'mousemove',
1494
+ 'keydown',
1495
+ 'touchstart',
1496
+ 'scroll',
1497
+ 'visibilitychange',
1498
+ ];
1499
+ const serverDate$1 = new Date();
1500
+ /**
1501
+ * Creates a read-only signal that flips to `true` after a window of user
1502
+ * inactivity. Any of the configured `events` (default: pointer/keyboard/scroll
1503
+ * activity) resets the timer and flips the signal back to `false`.
1504
+ *
1505
+ * SSR-safe — always `false` with a frozen `since` date on the server.
1506
+ *
1507
+ * @example
1508
+ * ```ts
1509
+ * const isAway = idle({ ms: 30_000 });
1510
+ * effect(() => {
1511
+ * if (isAway()) console.log('idle since', isAway.since());
1512
+ * });
1513
+ * ```
1514
+ */
1515
+ function idle(opt) {
1516
+ if (isPlatformServer(inject(PLATFORM_ID))) {
1517
+ const sig = computed(() => false, {
1518
+ debugName: opt?.debugName ?? 'idle',
1519
+ });
1520
+ sig.since = computed(() => serverDate$1);
1521
+ return sig;
1522
+ }
1523
+ const ms = opt?.ms ?? 60_000;
1524
+ const events = opt?.events ?? DEFAULT_EVENTS;
1525
+ const state = signal(false, { debugName: opt?.debugName ?? 'idle' });
1526
+ const since = signal(new Date());
1527
+ let timer;
1528
+ const goIdle = () => {
1529
+ if (state())
1530
+ return;
1531
+ state.set(true);
1532
+ since.set(new Date());
1533
+ };
1534
+ const reset = () => {
1535
+ if (timer)
1536
+ clearTimeout(timer);
1537
+ if (state()) {
1538
+ state.set(false);
1539
+ since.set(new Date());
1540
+ }
1541
+ timer = setTimeout(goIdle, ms);
1542
+ };
1543
+ const abortController = new AbortController();
1544
+ for (const ev of events) {
1545
+ window.addEventListener(ev, reset, {
1546
+ passive: true,
1547
+ signal: abortController.signal,
1548
+ });
1549
+ }
1550
+ timer = setTimeout(goIdle, ms);
1551
+ inject(DestroyRef).onDestroy(() => {
1552
+ if (timer)
1553
+ clearTimeout(timer);
1554
+ abortController.abort();
1555
+ });
1556
+ const sig = state.asReadonly();
1557
+ sig.since = since.asReadonly();
1558
+ return sig;
1559
+ }
1560
+
1137
1561
  /**
1138
1562
  * Creates a read-only signal that reactively tracks whether a CSS media query
1139
1563
  * string currently matches.
@@ -1295,26 +1719,41 @@ function throttled(initial, opt) {
1295
1719
  */
1296
1720
  function throttle(source, opt) {
1297
1721
  const ms = opt?.ms ?? 0;
1722
+ const leading = opt?.leading ?? false;
1723
+ const trailing = opt?.trailing ?? true;
1298
1724
  const trigger = signal(false);
1725
+ const fire = () => trigger.update((c) => !c);
1299
1726
  let timeout;
1727
+ let pendingTrailing = false;
1300
1728
  try {
1301
1729
  const destroyRef = opt?.destroyRef ?? inject(DestroyRef, { optional: true });
1302
1730
  destroyRef?.onDestroy(() => {
1303
1731
  if (timeout)
1304
1732
  clearTimeout(timeout);
1305
1733
  timeout = undefined;
1734
+ pendingTrailing = false;
1306
1735
  });
1307
1736
  }
1308
1737
  catch {
1309
1738
  // not in injection context & no destroyRef provided opting out of cleanup
1310
1739
  }
1311
1740
  const tick = () => {
1312
- if (timeout)
1741
+ if (!timeout) {
1742
+ if (leading)
1743
+ fire();
1744
+ else
1745
+ pendingTrailing = trailing;
1746
+ timeout = setTimeout(() => {
1747
+ timeout = undefined;
1748
+ if (trailing && pendingTrailing) {
1749
+ pendingTrailing = false;
1750
+ fire();
1751
+ }
1752
+ }, ms);
1313
1753
  return;
1314
- timeout = setTimeout(() => {
1315
- trigger.update((c) => !c);
1316
- timeout = undefined;
1317
- }, ms);
1754
+ }
1755
+ if (trailing)
1756
+ pendingTrailing = true;
1318
1757
  };
1319
1758
  const set = (value) => {
1320
1759
  source.set(value);
@@ -1435,6 +1874,14 @@ const serverDate = new Date();
1435
1874
  *
1436
1875
  * @param debugName Optional debug name for the signal.
1437
1876
  * @returns A `NetworkStatusSignal` instance.
1877
+ *
1878
+ * @example
1879
+ * ```ts
1880
+ * const online = networkStatus();
1881
+ * effect(() => {
1882
+ * if (!online()) console.log('offline since', online.since());
1883
+ * });
1884
+ * ```
1438
1885
  */
1439
1886
  function networkStatus(debugName = 'networkStatus') {
1440
1887
  if (isPlatformServer(inject(PLATFORM_ID))) {
@@ -1467,6 +1914,46 @@ function networkStatus(debugName = 'networkStatus') {
1467
1914
  return sig;
1468
1915
  }
1469
1916
 
1917
+ const SSR_FALLBACK = {
1918
+ angle: 0,
1919
+ type: 'portrait-primary',
1920
+ };
1921
+ /**
1922
+ * Creates a read-only signal that tracks `screen.orientation`.
1923
+ *
1924
+ * SSR-safe — returns a constant `portrait-primary / 0°` signal on the server
1925
+ * and in environments without `screen.orientation` support.
1926
+ *
1927
+ * @example
1928
+ * ```ts
1929
+ * const screenOrientation = orientation();
1930
+ * effect(() => {
1931
+ * const { type, angle } = screenOrientation();
1932
+ * console.log(`${type} at ${angle}°`);
1933
+ * });
1934
+ * ```
1935
+ */
1936
+ function orientation(debugName = 'orientation') {
1937
+ if (isPlatformServer(inject(PLATFORM_ID)) ||
1938
+ typeof screen === 'undefined' ||
1939
+ !screen.orientation) {
1940
+ return computed(() => SSR_FALLBACK, { debugName });
1941
+ }
1942
+ const so = screen.orientation;
1943
+ const read = () => ({
1944
+ angle: so.angle,
1945
+ type: so.type,
1946
+ });
1947
+ const state = signal(read(), {
1948
+ debugName,
1949
+ equal: (a, b) => a.angle === b.angle && a.type === b.type,
1950
+ });
1951
+ const onChange = () => state.set(read());
1952
+ so.addEventListener('change', onChange);
1953
+ inject(DestroyRef).onDestroy(() => so.removeEventListener('change', onChange));
1954
+ return state.asReadonly();
1955
+ }
1956
+
1470
1957
  /**
1471
1958
  * Creates a read-only signal that tracks the page's visibility state.
1472
1959
  *
@@ -1683,39 +2170,67 @@ function windowSize(opt) {
1683
2170
  * @internal
1684
2171
  */
1685
2172
  function sensor(type, options) {
2173
+ const opts = options;
1686
2174
  switch (type) {
1687
2175
  case 'mousePosition':
1688
- return mousePosition(options);
2176
+ return mousePosition(opts);
1689
2177
  case 'networkStatus':
1690
- return networkStatus(options?.debugName);
2178
+ return networkStatus(opts?.debugName);
1691
2179
  case 'pageVisibility':
1692
- return pageVisibility(options?.debugName);
2180
+ return pageVisibility(opts?.debugName);
1693
2181
  case 'darkMode':
1694
2182
  case 'dark-mode':
1695
- return prefersDarkMode(options?.debugName);
2183
+ return prefersDarkMode(opts?.debugName);
1696
2184
  case 'reducedMotion':
1697
2185
  case 'reduced-motion':
1698
- return prefersReducedMotion(options?.debugName);
1699
- case 'mediaQuery': {
1700
- const opt = options;
1701
- return mediaQuery(opt.query, opt.debugName);
1702
- }
2186
+ return prefersReducedMotion(opts?.debugName);
2187
+ case 'mediaQuery':
2188
+ return mediaQuery(opts.query, opts.debugName);
1703
2189
  case 'windowSize':
1704
- return windowSize(options);
2190
+ return windowSize(opts);
1705
2191
  case 'scrollPosition':
1706
- return scrollPosition(options);
1707
- case 'elementVisibility': {
1708
- const opt = options;
1709
- return elementVisibility(opt.target, opt);
1710
- }
1711
- case 'elementSize': {
1712
- const opt = options;
1713
- return elementSize(opt.target, opt);
1714
- }
2192
+ return scrollPosition(opts);
2193
+ case 'elementVisibility':
2194
+ return elementVisibility(opts?.target, opts);
2195
+ case 'elementSize':
2196
+ return elementSize(opts?.target, opts);
2197
+ case 'geolocation':
2198
+ return geolocation(opts);
2199
+ case 'clipboard':
2200
+ return clipboard(opts?.debugName);
2201
+ case 'orientation':
2202
+ return orientation(opts?.debugName);
2203
+ case 'batteryStatus':
2204
+ return batteryStatus(opts?.debugName);
2205
+ case 'idle':
2206
+ return idle(opts);
2207
+ case 'focusWithin':
2208
+ return focusWithin(opts?.target);
1715
2209
  default:
1716
2210
  throw new Error(`Unknown sensor type: ${type}`);
1717
2211
  }
1718
2212
  }
2213
+ /**
2214
+ * Bulk sensor factory — creates several sensor signals at once and returns
2215
+ * them keyed by sensor type. Convenient when a single consumer needs to react
2216
+ * to multiple browser signals; for a single sensor prefer {@link sensor}
2217
+ * directly.
2218
+ *
2219
+ * @typeParam TType The union of sensor keys being requested.
2220
+ * @param track Array of sensor type keys to create.
2221
+ * @param opt Optional per-sensor options keyed by sensor type.
2222
+ * @returns A record `{ [key]: <SensorReturnType> }` for each requested key.
2223
+ *
2224
+ * @example
2225
+ * ```ts
2226
+ * const { windowSize, networkStatus } = sensors(
2227
+ * ['windowSize', 'networkStatus'],
2228
+ * { windowSize: { throttle: 200 } },
2229
+ * );
2230
+ *
2231
+ * effect(() => console.log(windowSize(), networkStatus()));
2232
+ * ```
2233
+ */
1719
2234
  function sensors(track, opt) {
1720
2235
  return track.reduce((result, key) => {
1721
2236
  result[key] = sensor(key, opt?.[key]);
@@ -1723,6 +2238,49 @@ function sensors(track, opt) {
1723
2238
  }, {});
1724
2239
  }
1725
2240
 
2241
+ function unwrap(t) {
2242
+ if (!t)
2243
+ return null;
2244
+ return t instanceof ElementRef ? t.nativeElement : t;
2245
+ }
2246
+ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
2247
+ const project = typeof projectOrOpt === 'function' ? projectOrOpt : undefined;
2248
+ const opt = typeof projectOrOpt === 'function' ? maybeOpt : projectOrOpt;
2249
+ const injector = opt?.injector ?? inject(Injector);
2250
+ if (isPlatformServer(injector.get(PLATFORM_ID))) {
2251
+ return computed(() => initial, { debugName: opt?.debugName });
2252
+ }
2253
+ const state = signal(initial, {
2254
+ debugName: opt?.debugName,
2255
+ });
2256
+ const handler = (event) => {
2257
+ if (project)
2258
+ state.set(project(event));
2259
+ else
2260
+ state.set(event);
2261
+ };
2262
+ const { destroyRef: providedDestroyRef, ...listenerOpts } = opt ?? {};
2263
+ if (isSignal(target)) {
2264
+ const targetSig = target;
2265
+ effect((cleanup) => {
2266
+ const resolved = unwrap(targetSig());
2267
+ if (!resolved)
2268
+ return;
2269
+ resolved.addEventListener(eventName, handler, listenerOpts);
2270
+ cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
2271
+ }, { injector });
2272
+ }
2273
+ else {
2274
+ const resolved = unwrap(target);
2275
+ if (resolved) {
2276
+ resolved.addEventListener(eventName, handler, listenerOpts);
2277
+ const destroyRef = providedDestroyRef ?? injector.get(DestroyRef);
2278
+ destroyRef.onDestroy(() => resolved.removeEventListener(eventName, handler, listenerOpts));
2279
+ }
2280
+ }
2281
+ return untracked(() => state.asReadonly());
2282
+ }
2283
+
1726
2284
  const IS_STORE = Symbol('MMSTACK::IS_STORE');
1727
2285
  const PROXY_CACHE = new WeakMap();
1728
2286
  const SIGNAL_FN_PROP = new Set([
@@ -2215,14 +2773,11 @@ function generateDeterministicID() {
2215
2773
  *
2216
2774
  * @template T - The type of the WritableSignal
2217
2775
  * @param sig - The WritableSignal to synchronize across tabs
2218
- * @param opt - Optional configuration object
2219
- * @param opt.id - Explicit channel ID for synchronization. If not provided,
2220
- * a deterministic ID is generated based on the call site.
2221
- * Use explicit IDs in production for reliability.
2776
+ * @param opt - configuration object
2777
+ * @param opt.id - Explicit channel ID for synchronization.
2222
2778
  *
2223
2779
  * @returns The same WritableSignal instance, now synchronized across tabs
2224
2780
  *
2225
- * @throws {Error} When deterministic ID generation fails and no explicit ID is provided
2226
2781
  *
2227
2782
  * @example
2228
2783
  * ```typescript
@@ -2246,7 +2801,7 @@ function generateDeterministicID() {
2246
2801
  function tabSync(sig, opt) {
2247
2802
  if (isPlatformServer(inject(PLATFORM_ID)))
2248
2803
  return sig;
2249
- const id = opt?.id || generateDeterministicID();
2804
+ const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
2250
2805
  const bus = inject(MessageBus);
2251
2806
  const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
2252
2807
  let first = false;
@@ -2378,8 +2933,13 @@ function getSignalEquality(sig) {
2378
2933
  * console.log('Can undo:', name.canUndo()); // false
2379
2934
  * ```
2380
2935
  */
2381
- function withHistory(source, opt) {
2382
- const equal = opt?.equal ?? getSignalEquality(source);
2936
+ function withHistory(sourceOrValue, opt) {
2937
+ const equal = (opt?.equal ?? isSignal(sourceOrValue))
2938
+ ? getSignalEquality(sourceOrValue)
2939
+ : Object.is;
2940
+ const source = isSignal(sourceOrValue)
2941
+ ? sourceOrValue
2942
+ : signal(sourceOrValue);
2383
2943
  const maxSize = opt?.maxSize ?? Infinity;
2384
2944
  const history = mutable([], {
2385
2945
  ...opt,
@@ -2387,20 +2947,22 @@ function withHistory(source, opt) {
2387
2947
  });
2388
2948
  const redoArray = mutable([]);
2389
2949
  const originalSet = source.set;
2950
+ const trim = (arr) => {
2951
+ if (arr.length < maxSize)
2952
+ return arr;
2953
+ if (opt?.cleanupStrategy === 'shift') {
2954
+ arr.shift();
2955
+ return arr;
2956
+ }
2957
+ return arr.slice(Math.floor(maxSize / 2));
2958
+ };
2390
2959
  const set = (value) => {
2391
2960
  const current = untracked(source);
2392
2961
  if (equal(value, current))
2393
2962
  return;
2394
2963
  source.set(value);
2395
2964
  history.mutate((c) => {
2396
- if (c.length >= maxSize) {
2397
- if (opt?.cleanupStrategy === 'shift') {
2398
- c.shift();
2399
- }
2400
- else {
2401
- c = c.slice(Math.floor(maxSize / 2));
2402
- }
2403
- }
2965
+ c = trim(c);
2404
2966
  c.push(current);
2405
2967
  return c;
2406
2968
  });
@@ -2424,7 +2986,11 @@ function withHistory(source, opt) {
2424
2986
  return;
2425
2987
  originalSet.call(source, valueToRestore);
2426
2988
  history.inline((h) => h.pop());
2427
- redoArray.inline((r) => r.push(valueForRedo));
2989
+ redoArray.mutate((r) => {
2990
+ r = trim(r);
2991
+ r.push(valueForRedo);
2992
+ return r;
2993
+ });
2428
2994
  };
2429
2995
  internal.redo = () => {
2430
2996
  const redoStack = untracked(redoArray);
@@ -2437,14 +3003,7 @@ function withHistory(source, opt) {
2437
3003
  originalSet.call(source, valueToRestore);
2438
3004
  redoArray.inline((r) => r.pop());
2439
3005
  history.mutate((h) => {
2440
- if (h.length >= maxSize) {
2441
- if (opt?.cleanupStrategy === 'shift') {
2442
- h.shift();
2443
- }
2444
- else {
2445
- h = h.slice(Math.floor(maxSize / 2));
2446
- }
2447
- }
3006
+ h = trim(h);
2448
3007
  h.push(valueForUndo);
2449
3008
  return h;
2450
3009
  });
@@ -2463,5 +3022,5 @@ function withHistory(source, opt) {
2463
3022
  * Generated bundle index. Do not edit.
2464
3023
  */
2465
3024
 
2466
- export { chunked, combineWith, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, indexArray, isDerivation, isMutable, isStore, keyArray, map, mapArray, mapObject, mediaQuery, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, pageVisibility, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, sensors, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
3025
+ export { batteryStatus, chunked, clipboard, combineWith, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, geolocation, idle, indexArray, isDerivation, isMutable, isStore, keyArray, map, mapArray, mapObject, mediaQuery, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, orientation, pageVisibility, pairwise, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
2467
3026
  //# sourceMappingURL=mmstack-primitives.mjs.map