@mmstack/primitives 19.2.0 → 19.2.2

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 CHANGED
@@ -23,8 +23,10 @@ This library provides the following primitives:
23
23
  - `mapArray` - Maps a reactive array efficently into an array of stable derivations.
24
24
  - `toWritable` - Converts a read-only signal to writable using custom write logic.
25
25
  - `derived` - Creates a signal with two-way binding to a source signal.
26
- - `sensor` - A facade function to create various reactive sensor signals (e.g., mouse position, network status, page visibility, dark mode preference)." (This makes it flow a bit better and more accurately lists what the facade produces.)
26
+ - `sensor` - A facade function to create various reactive sensor signals (e.g., mouse position, network status, page visibility, dark mode preference)." (This was the suggestion from before; it just reads a little smoother and more accurately reflects what the facade creates directly).
27
27
  - `until` - Creates a Promise that resolves when a signal's value meets a specific condition.
28
+ - `mediaQuery` - A generic primitive that tracks a CSS media query (forms the basis for `prefersDarkMode` and `prefersReducedMotion`).
29
+ - `elementVisibility` - Tracks if an element is intersecting the viewport using IntersectionObserver.
28
30
 
29
31
  ---
30
32
 
@@ -289,6 +291,8 @@ Enhances any WritableSignal with a complete undo/redo history stack. This is use
289
291
  ```typescript
290
292
  import { FormsModule } from '@angular/forms';
291
293
  import { JsonPipe } from '@angular/common';
294
+ import { withHistory } from '@mmstack/primitives';
295
+ import { Component, signal, effect } from '@angular/core';
292
296
 
293
297
  @Component({
294
298
  selector: 'app-history-demo',
@@ -321,9 +325,13 @@ export class HistoryDemoComponent {
321
325
 
322
326
  ### sensor
323
327
 
324
- The sensor() facade provides a unified way to create various reactive sensor signals that track browser events, states, and user preferences. You specify the type of sensor you want, and it returns the corresponding signal, often with specific properties or methods. These primitives are generally SSR-safe and handle their own event listener cleanup.
328
+ ### sensor
329
+
330
+ The `sensor()` facade provides a unified way to create various reactive sensor signals that track browser events, states, and user preferences. You specify the type of sensor you want (e.g., `'mousePosition'`, `'networkStatus'`, `'windowSize'`, `'dark-mode'`), and it returns the corresponding signal, often with specific properties or methods. These primitives are generally SSR-safe and handle their own event listener cleanup.
331
+
332
+ You can either use the `sensor('sensorType', options)` facade or import the specific sensor functions directly if you prefer.
325
333
 
326
- You can either use the sensor('sensorType', options) facade or import the specific sensor functions directly.
334
+ **Facade Usage Example:**
327
335
 
328
336
  ```typescript
329
337
  import { sensor } from '@mmstack/primitives';
@@ -331,10 +339,12 @@ import { effect } from '@angular/core';
331
339
 
332
340
  const network = sensor('networkStatus');
333
341
  const mouse = sensor('mousePosition', { throttle: 50, coordinateSpace: 'page' });
342
+ const winSize = sensor('windowSize', { throttle: 150 });
334
343
  const isDark = sensor('dark-mode');
335
344
 
336
345
  effect(() => console.log('Online:', network().isOnline));
337
346
  effect(() => console.log('Mouse X:', mouse().x));
347
+ effect(() => console.log('Window Width:', winSize().width));
338
348
  effect(() => console.log('Dark Mode Preferred:', isDark()));
339
349
  ```
340
350
 
@@ -439,6 +449,73 @@ export class VisibilityLoggerComponent {
439
449
  }
440
450
  ```
441
451
 
452
+ #### windowSize
453
+
454
+ Tracks the browser window's inner dimensions (width and height). Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
455
+
456
+ ```typescript
457
+ import { Component, effect, computed } from '@angular/core';
458
+ import { sensor } from '@mmstack/primitives'; // Or import { windowSize }
459
+
460
+ @Component({
461
+ selector: 'app-responsive-display',
462
+ standalone: true,
463
+ template: `
464
+ <p>Current Window Size: {{ winSize().width }}px x {{ winSize().height }}px</p>
465
+ <p>Unthrottled: W: {{ winSize.unthrottled().width }} H: {{ winSize.unthrottled().height }}</p>
466
+ @if (isMobileDisplay()) {
467
+ <p>Displaying mobile layout.</p>
468
+ } @else {
469
+ <p>Displaying desktop layout.</p>
470
+ }
471
+ `,
472
+ })
473
+ export class ResponsiveDisplayComponent {
474
+ readonly winSize = sensor('windowSize', { throttle: 150 });
475
+ // Or: readonly winSize = windowSize({ throttle: 150 });
476
+
477
+ readonly isMobileDisplay = computed(() => this.winSize().width < 768);
478
+
479
+ constructor() {
480
+ effect(() => console.log('Window Size (Throttled):', this.winSize()));
481
+ }
482
+ }
483
+ ```
484
+
485
+ #### scrollPosition
486
+
487
+ Tracks the scroll position (x, y) of the window or a specified HTML element. Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
488
+
489
+ ```typescript
490
+ import { Component, effect, ElementRef, viewChild } from '@angular/core';
491
+ import { sensor } from '@mmstack/primitives'; // Or import { scrollPosition }
492
+ import { JsonPipe } from '@angular/common';
493
+
494
+ @Component({
495
+ selector: 'app-scroll-indicator',
496
+ standalone: true,
497
+ imports: [JsonPipe],
498
+ template: `
499
+ <div style="height: 100px; border-bottom: 2px solid red; position: fixed; top: 0; left: 0; width: 100%; background: white; z-index: 10;">
500
+ Page Scroll Y: {{ pageScroll().y }}px
501
+ <p>Unthrottled Y: {{ pageScroll.unthrottled().y }}</p>
502
+ </div>
503
+ <div #scrollableContent style="height: 2000px; padding-top: 120px;">Scroll down...</div>
504
+ `,
505
+ })
506
+ export class ScrollIndicatorComponent {
507
+ readonly pageScroll = sensor('scrollPosition', { throttle: 50 });
508
+ // Or: readonly pageScroll = scrollPosition({ throttle: 50 });
509
+
510
+ constructor() {
511
+ effect(() => {
512
+ // Example: Change header style based on scroll
513
+ console.log('Page scroll Y (Throttled):', this.pageScroll().y);
514
+ });
515
+ }
516
+ }
517
+ ```
518
+
442
519
  #### mediaQuery, prefersDarkMode() & prefersReducedMotion()
443
520
 
444
521
  A generic mediaQuery primitive, you can use directly for any CSS media query. Two specific versions have been created for `prefersDarkMode()` & `prefersReducedMotion()`.
@@ -499,3 +576,44 @@ it('should reject on timeout if the condition is not met in time', async () => {
499
576
  await expect(untilPromise).toThrow(`until: Timeout after ${timeoutDuration}ms.`);
500
577
  });
501
578
  ```
579
+
580
+ ### elementVisibility
581
+
582
+ Tracks if a target DOM element is intersecting with the viewport (or a specified root element) using the `IntersectionObserver` API. This is highly performant for use cases like lazy-loading content or triggering animations when elements scroll into view.
583
+
584
+ It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one, allowing for dynamic targets. The returned signal emits the full `IntersectionObserverEntry` object (or `undefined`) & exposes a sub-signal `.visible` which is just a boolean signal for ease of use
585
+
586
+ ```typescript
587
+ import { Component, effect, ElementRef, viewChild, computed } from '@angular/core';
588
+ import { elementVisibility } from '@mmstack/primitives';
589
+
590
+ @Component({
591
+ selector: 'app-lazy-load-item',
592
+ standalone: true,
593
+ template: `
594
+ <div #itemToObserve style="height: 300px; margin-top: 100vh; border: 2px solid green;">
595
+ @if (intersectionEntry.isVisible()) {
596
+ <p>This content was lazy-loaded because it became visible!</p>
597
+ } @else {
598
+ <p>Item is off-screen. Scroll down to load it.</p>
599
+ }
600
+ </div>
601
+ `,
602
+ })
603
+ export class LazyLoadItemComponent {
604
+ readonly itemRef = viewChild.required<ElementRef<HTMLDivElement>>('itemToObserve');
605
+
606
+ // Observe the element, get the full IntersectionObserverEntry
607
+ readonly intersectionEntry = elementVisibility(this.itemRef);
608
+
609
+ constructor() {
610
+ effect(() => {
611
+ if (this.isVisible()) {
612
+ console.log('Item is now visible!', this.intersectionEntry());
613
+ } else {
614
+ console.log('Item is no longer visible or not yet visible.');
615
+ }
616
+ });
617
+ }
618
+ }
619
+ ```
@@ -1,4 +1,4 @@
1
- import { untracked, signal, inject, DestroyRef, computed, isSignal, linkedSignal, PLATFORM_ID, ElementRef, isDevMode, effect } from '@angular/core';
1
+ import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode, Injector, runInInjectionContext } from '@angular/core';
2
2
  import { isPlatformServer } from '@angular/common';
3
3
  import { SIGNAL } from '@angular/core/primitives/signals';
4
4
 
@@ -201,6 +201,108 @@ function isDerivation(sig) {
201
201
  return 'from' in sig;
202
202
  }
203
203
 
204
+ function observerSupported() {
205
+ return typeof IntersectionObserver !== 'undefined';
206
+ }
207
+ /**
208
+ * Creates a read-only signal that tracks the intersection status of a target DOM element
209
+ * with the viewport or a specified root element, using the `IntersectionObserver` API.
210
+ *
211
+ * It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one,
212
+ * allowing for dynamic targets.
213
+ *
214
+ * @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
215
+ * If the signal resolves to `null`, observation stops.
216
+ * @param options Optional `IntersectionObserverInit` options (e.g., `root`, `rootMargin`, `threshold`)
217
+ * and an optional `debugName`.
218
+ * @returns A `Signal<IntersectionObserverEntry | undefined>`. It emits `undefined` initially,
219
+ * on the server, or if the target is `null`. Otherwise, it emits the latest
220
+ * `IntersectionObserverEntry`. Consumers can derive a boolean `isVisible` from
221
+ * this entry's `isIntersecting` property.
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * import { Component, effect, ElementRef, viewChild } from '@angular/core';
226
+ * import { elementVisibility } from '@mmstack/primitives';
227
+ * import { computed } from '@angular/core'; // For derived boolean
228
+ *
229
+ * @Component({
230
+ * selector: 'app-lazy-image',
231
+ * template: `
232
+ * <div #imageContainer style="height: 200px; border: 1px dashed grey;">
233
+ * @if (isVisible()) {
234
+ * <img src="your-image-url.jpg" alt="Lazy loaded image" />
235
+ * <p>Image is VISIBLE!</p>
236
+ * } @else {
237
+ * <p>Scroll down to see the image...</p>
238
+ * }
239
+ * </div>
240
+ * `
241
+ * })
242
+ * export class LazyImageComponent {
243
+ * readonly imageContainer = viewChild.required<ElementRef<HTMLDivElement>>('imageContainer');
244
+ *
245
+ * // Observe the element, get the full IntersectionObserverEntry
246
+ * readonly intersectionEntry = elementVisibility(this.imageContainer);
247
+ *
248
+ * // Derive a simple boolean for visibility
249
+ * readonly isVisible = computed(() => this.intersectionEntry()?.isIntersecting ?? false);
250
+ *
251
+ * constructor() {
252
+ * effect(() => {
253
+ * console.log('Intersection Entry:', this.intersectionEntry());
254
+ * console.log('Is Visible:', this.isVisible());
255
+ * });
256
+ * }
257
+ * }
258
+ * ```
259
+ */
260
+ function elementVisibility(target, opt) {
261
+ if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
262
+ const base = computed(() => undefined, {
263
+ debugName: opt?.debugName,
264
+ });
265
+ base.visible = computed(() => false);
266
+ return base;
267
+ }
268
+ const state = signal(undefined, {
269
+ debugName: opt?.debugName,
270
+ equal: (a, b) => {
271
+ if (!a && !b)
272
+ return true;
273
+ if (!a || !b)
274
+ return false;
275
+ return (a.target === b.target &&
276
+ a.isIntersecting === b.isIntersecting &&
277
+ a.intersectionRatio === b.intersectionRatio &&
278
+ a.boundingClientRect.top === b.boundingClientRect.top &&
279
+ a.boundingClientRect.left === b.boundingClientRect.left &&
280
+ a.boundingClientRect.width === b.boundingClientRect.width &&
281
+ a.boundingClientRect.height === b.boundingClientRect.height);
282
+ },
283
+ });
284
+ const targetSignal = isSignal(target) ? target : computed(() => target);
285
+ effect((cleanup) => {
286
+ const el = targetSignal();
287
+ if (!el)
288
+ return state.set(undefined);
289
+ let observer = null;
290
+ observer = new IntersectionObserver(([entry]) => state.set(entry), opt);
291
+ observer.observe(el instanceof ElementRef ? el.nativeElement : el);
292
+ cleanup(() => {
293
+ observer?.disconnect();
294
+ });
295
+ });
296
+ const base = state.asReadonly();
297
+ base.visible = computed(() => {
298
+ const s = state();
299
+ if (!s)
300
+ return false;
301
+ return s.isIntersecting;
302
+ });
303
+ return base;
304
+ }
305
+
204
306
  /**
205
307
  * Reactively maps items from a source array (or signal of an array) using a provided mapping function.
206
308
  *
@@ -712,6 +814,169 @@ function pageVisibility(debugName) {
712
814
  return visibility.asReadonly();
713
815
  }
714
816
 
817
+ /**
818
+ * Creates a read-only signal that tracks the scroll position (x, y) of the window
819
+ * or a specified HTML element.
820
+ *
821
+ * Updates are throttled by default to optimize performance. An `unthrottled`
822
+ * property is available on the returned signal for direct access to raw updates.
823
+ * The primitive is SSR-safe and automatically cleans up its event listeners.
824
+ *
825
+ * @param options Optional configuration for the scroll sensor.
826
+ * @returns A `ScrollPositionSignal`. On the server, it returns a static
827
+ * signal with `{ x: 0, y: 0 }`.
828
+ *
829
+ * @example
830
+ * ```ts
831
+ * import { Component, effect, ElementRef, viewChild } from '@angular/core';
832
+ * import { scrollPosition } from '@mmstack/primitives';
833
+ *
834
+ * @Component({
835
+ * selector: 'app-scroll-tracker',
836
+ * template: `
837
+ * <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
838
+ * <div #scrollableDiv style="height: 200px; width: 200px; overflow: auto; border: 1px solid black;">
839
+ * <div style="height: 400px; width: 400px;">Scroll me!</div>
840
+ * </div>
841
+ * @if (divScroll()) {
842
+ * <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
843
+ * }
844
+ * `
845
+ * })
846
+ * export class ScrollTrackerComponent {
847
+ * readonly windowScroll = scrollPosition(); // Defaults to window
848
+ * readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
849
+ * readonly divScroll = scrollPosition({ target: this.scrollableDiv() }); // Example with element target
850
+ *
851
+ * constructor() {
852
+ * effect(() => {
853
+ * console.log('Window scrolled to:', this.windowScroll());
854
+ * if (this.divScroll()) {
855
+ * console.log('Div scrolled to:', this.divScroll());
856
+ * }
857
+ * });
858
+ * }
859
+ * }
860
+ * ```
861
+ */
862
+ function scrollPosition(opt) {
863
+ if (isPlatformServer(inject(PLATFORM_ID))) {
864
+ const base = computed(() => ({
865
+ x: 0,
866
+ y: 0,
867
+ }), {
868
+ debugName: opt?.debugName,
869
+ });
870
+ base.unthrottled = base;
871
+ return base;
872
+ }
873
+ const { target = window, throttle = 100, debugName } = opt || {};
874
+ let element;
875
+ let getScrollPosition;
876
+ if (target instanceof Window) {
877
+ element = target;
878
+ getScrollPosition = () => {
879
+ return { x: target.scrollX, y: target.scrollY };
880
+ };
881
+ }
882
+ else if (target instanceof ElementRef) {
883
+ element = target.nativeElement;
884
+ getScrollPosition = () => {
885
+ return {
886
+ x: target.nativeElement.scrollLeft,
887
+ y: target.nativeElement.scrollTop,
888
+ };
889
+ };
890
+ }
891
+ else {
892
+ element = target;
893
+ getScrollPosition = () => {
894
+ return {
895
+ x: target.scrollLeft,
896
+ y: target.scrollTop,
897
+ };
898
+ };
899
+ }
900
+ const state = throttled(getScrollPosition(), {
901
+ debugName,
902
+ equal: (a, b) => a.x === b.x && a.y === b.y,
903
+ ms: throttle,
904
+ });
905
+ const onScroll = () => state.set(getScrollPosition());
906
+ element.addEventListener('scroll', onScroll, { passive: true });
907
+ inject(DestroyRef).onDestroy(() => element.removeEventListener('scroll', onScroll));
908
+ const base = state.asReadonly();
909
+ base.unthrottled = state.original;
910
+ return base;
911
+ }
912
+
913
+ /**
914
+ * Creates a read-only signal that tracks the browser window's inner dimensions (width and height).
915
+ *
916
+ * Updates are throttled by default (100ms) to optimize performance during resize events.
917
+ * An `unthrottled` property is available on the returned signal for direct access to raw updates.
918
+ * The primitive is SSR-safe (returns a default size on the server) and automatically
919
+ * cleans up its event listeners.
920
+ *
921
+ * @param opt Optional configuration, including `throttle` (ms) and `debugName`.
922
+ * @returns A `WindowSizeSignal` (a `Signal<WindowSize>` with an `unthrottled` property).
923
+ *
924
+ * @example
925
+ * ```ts
926
+ * import { Component, effect } from '@angular/core';
927
+ * import { windowSize } from '@mmstack/primitives';
928
+ *
929
+ * @Component({
930
+ * selector: 'app-responsive-header',
931
+ * template: `
932
+ * <header>
933
+ * Current Window Size: {{ size().width }}px x {{ size().height }}px
934
+ * @if (isMobile()) {
935
+ * <p>Mobile Menu</p>
936
+ * } @else {
937
+ * <p>Desktop Menu</p>
938
+ * }
939
+ * </header>
940
+ * `
941
+ * })
942
+ * export class ResponsiveHeaderComponent {
943
+ * readonly size = windowSize();
944
+ * readonly isMobile = computed(() => this.size().width < 768);
945
+ *
946
+ * constructor() {
947
+ * effect(() => {
948
+ * console.log('Window resized to:', this.size());
949
+ * });
950
+ * }
951
+ * }
952
+ * ```
953
+ */
954
+ function windowSize(opt) {
955
+ if (isPlatformServer(inject(PLATFORM_ID))) {
956
+ const base = computed(() => ({
957
+ width: 1024,
958
+ height: 768,
959
+ }), { debugName: opt?.debugName });
960
+ base.unthrottled = base;
961
+ return base;
962
+ }
963
+ const sizeSignal = throttled({ width: window.innerWidth, height: window.innerHeight }, {
964
+ debugName: opt?.debugName,
965
+ equal: (a, b) => a.width === b.width && a.height === b.height,
966
+ ms: opt?.throttle ?? 100,
967
+ });
968
+ const onResize = () => {
969
+ sizeSignal.set({ width: window.innerWidth, height: window.innerHeight });
970
+ };
971
+ window.addEventListener('resize', onResize);
972
+ inject(DestroyRef).onDestroy(() => {
973
+ window.removeEventListener('resize', onResize);
974
+ });
975
+ const base = sizeSignal.asReadonly();
976
+ base.unthrottled = sizeSignal.original;
977
+ return base;
978
+ }
979
+
715
980
  /**
716
981
  * Implementation for sensor overloads.
717
982
  * Users should refer to the specific overloads for detailed documentation.
@@ -729,6 +994,10 @@ function sensor(type, options) {
729
994
  return prefersDarkMode(options?.debugName);
730
995
  case 'reduced-motion':
731
996
  return prefersReducedMotion(options?.debugName);
997
+ case 'windowSize':
998
+ return windowSize(options);
999
+ case 'scrollPosition':
1000
+ return scrollPosition(options);
732
1001
  default:
733
1002
  throw new Error(`Unknown sensor type: ${type}`);
734
1003
  }
@@ -928,6 +1197,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
928
1197
  * ```
929
1198
  */
930
1199
  function until(sourceSignal, predicate, options = {}) {
1200
+ const injector = options.injector ?? inject(Injector);
931
1201
  return new Promise((resolve, reject) => {
932
1202
  let effectRef;
933
1203
  let timeoutId;
@@ -967,14 +1237,16 @@ function until(sourceSignal, predicate, options = {}) {
967
1237
  if (options?.timeout !== undefined) {
968
1238
  timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
969
1239
  }
970
- effectRef = effect(() => {
971
- if (settled) {
972
- return effectRef?.destroy();
973
- }
974
- const currentValue = sourceSignal();
975
- if (predicate(currentValue)) {
976
- cleanupAndResolve(currentValue);
977
- }
1240
+ runInInjectionContext(injector, () => {
1241
+ effectRef = effect(() => {
1242
+ if (settled) {
1243
+ return effectRef?.destroy();
1244
+ }
1245
+ const currentValue = sourceSignal();
1246
+ if (predicate(currentValue)) {
1247
+ cleanupAndResolve(currentValue);
1248
+ }
1249
+ });
978
1250
  });
979
1251
  });
980
1252
  }
@@ -1118,5 +1390,5 @@ function withHistory(source, opt) {
1118
1390
  * Generated bundle index. Do not edit.
1119
1391
  */
1120
1392
 
1121
- export { debounce, debounced, derived, isDerivation, isMutable, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, prefersDarkMode, prefersReducedMotion, sensor, stored, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, withHistory };
1393
+ export { debounce, debounced, derived, elementVisibility, isDerivation, isMutable, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, prefersDarkMode, prefersReducedMotion, scrollPosition, sensor, stored, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, windowSize, withHistory };
1122
1394
  //# sourceMappingURL=mmstack-primitives.mjs.map