@mmstack/primitives 20.4.3 → 20.4.5
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 +18 -28
- package/fesm2022/mmstack-primitives.mjs +204 -2
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +140 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -214,40 +214,30 @@ export class ThemeSelectorComponent {
|
|
|
214
214
|
|
|
215
215
|
### piped
|
|
216
216
|
|
|
217
|
-
Adds
|
|
217
|
+
Adds two fluent APIs to signals:
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
import { piped, pipeable } from '@mmstack/primitives';
|
|
219
|
+
- **`.map(...transforms, [options])`** – compose pure, synchronous value→value transforms. Returns a computed signal that remains pipeable.
|
|
220
|
+
- **`.pipe(...operators)`** – compose operators (signal→signal), useful for combining signals or reusable projections.
|
|
222
221
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
})
|
|
227
|
-
export class PipeableComponent {
|
|
228
|
-
count = piped(1);
|
|
222
|
+
```typescript
|
|
223
|
+
import { piped, pipeable, select, combineWith } from '@mmstack/primitives';
|
|
224
|
+
import { signal } from '@angular/core';
|
|
229
225
|
|
|
230
|
-
|
|
231
|
-
label = this.count.pipe(
|
|
232
|
-
(n) => n * 2, // number -> number
|
|
233
|
-
(n) => `#${n}`, // number -> string
|
|
234
|
-
);
|
|
226
|
+
const count = piped(1);
|
|
235
227
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
228
|
+
// Map: value -> value
|
|
229
|
+
const label = count.map(
|
|
230
|
+
(n) => n * 2,
|
|
231
|
+
(n) => (num: n),
|
|
232
|
+
{ equal: (a, b) => a.num === b.num },
|
|
233
|
+
);
|
|
241
234
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
235
|
+
// Pipe: signal -> signal
|
|
236
|
+
const base = pipeable(signal(10));
|
|
237
|
+
const total = count.pipe(select((n) => n * 3)).pipe(combineWith(count, (a, b) => a + b));
|
|
246
238
|
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
const example2 = pipeable(signal(1)); // PipeableSignal<number, WritableSignal<number>> (a writable signal + pipe)
|
|
250
|
-
const example3 = pipeable(mutable({ name: 'john' })); // This returns a pipeable mutable signal (you get the point :) )
|
|
239
|
+
label(); // e.g., "#2"
|
|
240
|
+
total(); // reactive sum
|
|
251
241
|
```
|
|
252
242
|
|
|
253
243
|
### mapArray
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode, Injectable, Injector, runInInjectionContext } from '@angular/core';
|
|
2
3
|
import { isPlatformServer } from '@angular/common';
|
|
3
4
|
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
4
5
|
|
|
@@ -477,6 +478,91 @@ function mapArray(source, map, options) {
|
|
|
477
478
|
});
|
|
478
479
|
}
|
|
479
480
|
|
|
481
|
+
/** Project with optional equality. Pure & sync. */
|
|
482
|
+
const select = (projector, opt) => (src) => computed(() => projector(src()), opt);
|
|
483
|
+
/** Combine with another signal using a projector. */
|
|
484
|
+
const combineWith = (other, project, opt) => (src) => computed(() => project(src(), other()), opt);
|
|
485
|
+
/** Only re-emit when equal(prev, next) is false. */
|
|
486
|
+
const distinct = (equal = Object.is) => (src) => computed(() => src(), { equal });
|
|
487
|
+
/** map to new value */
|
|
488
|
+
const map = (fn) => (src) => computed(() => fn(src()));
|
|
489
|
+
/** filter values, keeping the last value if it was ever available, if first value is filtered will return undefined */
|
|
490
|
+
const filter = (predicate) => (src) => linkedSignal({
|
|
491
|
+
source: src,
|
|
492
|
+
computation: (next, prev) => {
|
|
493
|
+
if (predicate(next))
|
|
494
|
+
return next;
|
|
495
|
+
return prev?.source;
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
/** tap into the value */
|
|
499
|
+
const tap = (fn) => (src) => {
|
|
500
|
+
effect(() => fn(src()));
|
|
501
|
+
return src;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Decorate any `Signal<T>` with a chainable `.pipe(...)` method.
|
|
506
|
+
*
|
|
507
|
+
* @example
|
|
508
|
+
* const s = pipeable(signal(1)); // WritableSignal<number> (+ pipe)
|
|
509
|
+
* const label = s.pipe(n => n * 2, n => `#${n}`); // Signal<string> (+ pipe)
|
|
510
|
+
* label(); // "#2"
|
|
511
|
+
*/
|
|
512
|
+
function pipeable(signal) {
|
|
513
|
+
const internal = signal;
|
|
514
|
+
const mapImpl = (...fns) => {
|
|
515
|
+
const last = fns.at(-1);
|
|
516
|
+
let opt;
|
|
517
|
+
if (last && typeof last !== 'function') {
|
|
518
|
+
fns = fns.slice(0, -1);
|
|
519
|
+
opt = last;
|
|
520
|
+
}
|
|
521
|
+
if (fns.length === 0)
|
|
522
|
+
return internal;
|
|
523
|
+
if (fns.length === 1) {
|
|
524
|
+
const fn = fns[0];
|
|
525
|
+
return pipeable(computed(() => fn(internal()), opt));
|
|
526
|
+
}
|
|
527
|
+
const transformer = (input) => fns.reduce((acc, fn) => fn(acc), input);
|
|
528
|
+
return pipeable(computed(() => transformer(internal()), opt));
|
|
529
|
+
};
|
|
530
|
+
const pipeImpl = (...ops) => {
|
|
531
|
+
if (ops.length === 0)
|
|
532
|
+
return internal;
|
|
533
|
+
return ops.reduce((src, op) => pipeable(op(src)), internal);
|
|
534
|
+
};
|
|
535
|
+
Object.defineProperties(internal, {
|
|
536
|
+
map: {
|
|
537
|
+
value: mapImpl,
|
|
538
|
+
configurable: true,
|
|
539
|
+
enumerable: false,
|
|
540
|
+
writable: false,
|
|
541
|
+
},
|
|
542
|
+
pipe: {
|
|
543
|
+
value: pipeImpl,
|
|
544
|
+
configurable: true,
|
|
545
|
+
enumerable: false,
|
|
546
|
+
writable: false,
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
return internal;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Create a new **writable** signal and return it as a `PipableSignal`.
|
|
553
|
+
*
|
|
554
|
+
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
555
|
+
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* const count = piped(1); // WritableSignal<number> (+ pipe)
|
|
559
|
+
* const even = count.pipe(n => n % 2 === 0); // Signal<boolean> (+ pipe)
|
|
560
|
+
* count.update(n => n + 1);
|
|
561
|
+
*/
|
|
562
|
+
function piped(initial, opt) {
|
|
563
|
+
return pipeable(signal(initial, opt));
|
|
564
|
+
}
|
|
565
|
+
|
|
480
566
|
/**
|
|
481
567
|
* Creates a read-only signal that reactively tracks whether a CSS media query
|
|
482
568
|
* string currently matches.
|
|
@@ -1208,6 +1294,122 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
1208
1294
|
return writable;
|
|
1209
1295
|
}
|
|
1210
1296
|
|
|
1297
|
+
class MessageBus {
|
|
1298
|
+
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
1299
|
+
listeners = new Map();
|
|
1300
|
+
subscribe(id, listener) {
|
|
1301
|
+
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
1302
|
+
const wrapped = (ev) => {
|
|
1303
|
+
try {
|
|
1304
|
+
if (ev.data?.id === id)
|
|
1305
|
+
listener(ev.data?.value);
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
// noop
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
this.channel.addEventListener('message', wrapped);
|
|
1312
|
+
this.listeners.set(id, wrapped);
|
|
1313
|
+
return {
|
|
1314
|
+
unsub: (() => this.unsubscribe(id)).bind(this),
|
|
1315
|
+
post: ((value) => this.channel.postMessage({ id, value })).bind(this),
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
unsubscribe(id) {
|
|
1319
|
+
const listener = this.listeners.get(id);
|
|
1320
|
+
if (!listener)
|
|
1321
|
+
return;
|
|
1322
|
+
this.channel.removeEventListener('message', listener);
|
|
1323
|
+
this.listeners.delete(id);
|
|
1324
|
+
}
|
|
1325
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1326
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
1327
|
+
}
|
|
1328
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, decorators: [{
|
|
1329
|
+
type: Injectable,
|
|
1330
|
+
args: [{
|
|
1331
|
+
providedIn: 'root',
|
|
1332
|
+
}]
|
|
1333
|
+
}] });
|
|
1334
|
+
function generateDeterministicID() {
|
|
1335
|
+
const stack = new Error().stack;
|
|
1336
|
+
if (stack) {
|
|
1337
|
+
// Look for the actual caller (first non-internal frame)
|
|
1338
|
+
const lines = stack.split('\n');
|
|
1339
|
+
for (let i = 2; i < lines.length; i++) {
|
|
1340
|
+
const line = lines[i];
|
|
1341
|
+
if (line && !line.includes('tabSync') && !line.includes('MessageBus')) {
|
|
1342
|
+
let hash = 0;
|
|
1343
|
+
for (let j = 0; j < line.length; j++) {
|
|
1344
|
+
const char = line.charCodeAt(j);
|
|
1345
|
+
hash = (hash << 5) - hash + char;
|
|
1346
|
+
hash = hash & hash;
|
|
1347
|
+
}
|
|
1348
|
+
return `auto-${Math.abs(hash)}`;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
throw new Error('Could not generate deterministic ID, please provide one manually.');
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Synchronizes a WritableSignal across browser tabs using BroadcastChannel API.
|
|
1356
|
+
*
|
|
1357
|
+
* Creates a shared signal that automatically syncs its value between all tabs
|
|
1358
|
+
* of the same application. When the signal is updated in one tab, all other
|
|
1359
|
+
* tabs will receive the new value automatically.
|
|
1360
|
+
*
|
|
1361
|
+
* @template T - The type of the WritableSignal
|
|
1362
|
+
* @param sig - The WritableSignal to synchronize across tabs
|
|
1363
|
+
* @param opt - Optional configuration object
|
|
1364
|
+
* @param opt.id - Explicit channel ID for synchronization. If not provided,
|
|
1365
|
+
* a deterministic ID is generated based on the call site.
|
|
1366
|
+
* Use explicit IDs in production for reliability.
|
|
1367
|
+
*
|
|
1368
|
+
* @returns The same WritableSignal instance, now synchronized across tabs
|
|
1369
|
+
*
|
|
1370
|
+
* @throws {Error} When deterministic ID generation fails and no explicit ID is provided
|
|
1371
|
+
*
|
|
1372
|
+
* @example
|
|
1373
|
+
* ```typescript
|
|
1374
|
+
* // Basic usage - auto-generates channel ID from call site
|
|
1375
|
+
* const theme = tabSync(signal('dark'));
|
|
1376
|
+
*
|
|
1377
|
+
* // With explicit ID (recommended for production)
|
|
1378
|
+
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
1379
|
+
*
|
|
1380
|
+
* // Changes in one tab will sync to all other tabs
|
|
1381
|
+
* theme.set('light'); // All tabs will update to 'light'
|
|
1382
|
+
* ```
|
|
1383
|
+
*
|
|
1384
|
+
* @remarks
|
|
1385
|
+
* - Only works in browser environments (returns original signal on server)
|
|
1386
|
+
* - Uses a single BroadcastChannel for all synchronized signals
|
|
1387
|
+
* - Automatically cleans up listeners when the injection context is destroyed
|
|
1388
|
+
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
1389
|
+
*
|
|
1390
|
+
*/
|
|
1391
|
+
function tabSync(sig, opt) {
|
|
1392
|
+
if (isPlatformServer(inject(PLATFORM_ID)))
|
|
1393
|
+
return sig;
|
|
1394
|
+
const id = opt?.id || generateDeterministicID();
|
|
1395
|
+
const bus = inject(MessageBus);
|
|
1396
|
+
const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
|
|
1397
|
+
let first = false;
|
|
1398
|
+
const effectRef = effect(() => {
|
|
1399
|
+
const val = sig();
|
|
1400
|
+
if (!first) {
|
|
1401
|
+
first = true;
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
post(val);
|
|
1405
|
+
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
|
|
1406
|
+
inject(DestroyRef).onDestroy(() => {
|
|
1407
|
+
effectRef.destroy();
|
|
1408
|
+
unsub();
|
|
1409
|
+
});
|
|
1410
|
+
return sig;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1211
1413
|
function until(sourceSignal, predicate, options = {}) {
|
|
1212
1414
|
const injector = options.injector ?? inject(Injector);
|
|
1213
1415
|
return new Promise((resolve, reject) => {
|
|
@@ -1406,5 +1608,5 @@ function withHistory(source, opt) {
|
|
|
1406
1608
|
* Generated bundle index. Do not edit.
|
|
1407
1609
|
*/
|
|
1408
1610
|
|
|
1409
|
-
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 };
|
|
1611
|
+
export { combineWith, debounce, debounced, derived, distinct, elementVisibility, filter, isDerivation, isMutable, map, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, pipeable, piped, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, windowSize, withHistory };
|
|
1410
1612
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|