@mmstack/primitives 19.0.4 → 19.1.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.
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/fesm2022/mmstack-primitives.mjs +335 -45
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +2 -0
- package/lib/debounced.d.ts +62 -54
- package/lib/map-array.d.ts +55 -53
- package/lib/mutable.d.ts +1 -1
- package/lib/stored.d.ts +126 -0
- package/lib/with-history.d.ts +14 -0
- package/package.json +19 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Miha J. Mulec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,230 @@
|
|
|
1
|
-
# mmstack/primitives
|
|
1
|
+
# @mmstack/primitives
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A collection of utility functions and primitives designed to enhance development with Angular Signals, providing helpful patterns and inspired by features from other reactive libraries. All value helpers also use pure derivations (no effects/RxJS).
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/js/%40mmstack%2Fprimitives)
|
|
6
|
+
[](https://github.com/mihajm/mmstack/blob/master/packages/primitives/LICENSE)
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @mmstack/primitives
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Primitives
|
|
15
|
+
|
|
16
|
+
This library provides the following primitives:
|
|
17
|
+
|
|
18
|
+
- `debounced` - Creates a writable signal whose value updates are debounced after set/update.
|
|
19
|
+
- `mutable` - A signal variant allowing in-place mutations while triggering updates.
|
|
20
|
+
- `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
|
|
21
|
+
- `mapArray` - Maps a reactive array efficently into an array of stable derivations.
|
|
22
|
+
- `toWritable` - Converts a read-only signal to writable using custom write logic.
|
|
23
|
+
- `derived` - Creates a signal with two-way binding to a source signal.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### debounced
|
|
28
|
+
|
|
29
|
+
Creates a WritableSignal where the propagation of its value (after calls to .set() or .update()) is delayed. The publicly readable signal value updates only after a specified time (ms) has passed without further set/update calls. It also includes an .original property, which is a Signal reflecting the value immediately after set/update is called.
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { Component, signal, effect } from '@angular/core';
|
|
33
|
+
import { debounced } from '@mmstack/primitives';
|
|
34
|
+
import { FormsModule } from '@angular/forms';
|
|
35
|
+
|
|
36
|
+
@Component({
|
|
37
|
+
selector: 'app-debounced',
|
|
38
|
+
template: `<input [(ngModel)]="searchTerm" />`,
|
|
39
|
+
})
|
|
40
|
+
export class SearchComponent {
|
|
41
|
+
searchTerm = debounced('', { ms: 300 }); // Debounce for 300ms
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
effect(() => {
|
|
45
|
+
// Runs 300ms after the user stops typing
|
|
46
|
+
console.log('Perform search for:', this.searchTerm());
|
|
47
|
+
});
|
|
48
|
+
effect(() => {
|
|
49
|
+
// Runs immediately on input change
|
|
50
|
+
console.log('Input value:', this.searchTerm.original());
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### mutable
|
|
57
|
+
|
|
58
|
+
Creates a MutableSignal, a signal variant designed for scenarios where you want to perform in-place mutations on objects or arrays held within the signal, while still ensuring Angular's change detection is correctly triggered. It provides .mutate() and .inline() methods alongside the standard .set() and .update(). Please note that any computeds, which resolve non-primitive values from a mutable require equals to be set to false.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { Component, computed, effect } from '@angular/core';
|
|
62
|
+
import { mutable } from '@mmstack/primitives';
|
|
63
|
+
import { FormsModule } from '@angular/forms';
|
|
64
|
+
|
|
65
|
+
@Component({
|
|
66
|
+
selector: 'app-mutable',
|
|
67
|
+
template: ` <button (click)="incrementAge()">inc</button> `,
|
|
68
|
+
})
|
|
69
|
+
export class SearchComponent {
|
|
70
|
+
user = mutable({ name: { first: 'John', last: 'Doe' }, age: 30 });
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
effect(() => {
|
|
74
|
+
// Runs every time user is mutated
|
|
75
|
+
console.log(this.user());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const age = computed(() => this.user().age);
|
|
79
|
+
|
|
80
|
+
effect(() => {
|
|
81
|
+
// Runs every time age changes
|
|
82
|
+
console.log(age());
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const name = computed(() => this.user().name);
|
|
86
|
+
effect(() => {
|
|
87
|
+
// Doesnt run if user changes, unless name is destructured
|
|
88
|
+
console.log(name());
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const name2 = computed(() => this.user().name, {
|
|
92
|
+
equal: () => false,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
effect(() => {
|
|
96
|
+
// Runs every time user changes (even if name did not change)
|
|
97
|
+
console.log(name2());
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
incrementAge() {
|
|
102
|
+
user.mutate((prev) => {
|
|
103
|
+
prev.age++;
|
|
104
|
+
return prev;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
incrementInline() {
|
|
109
|
+
user.inline((prev) => {
|
|
110
|
+
prev.age++;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### stored
|
|
117
|
+
|
|
118
|
+
Creates a WritableSignal whose state is automatically synchronized with persistent storage (like localStorage or sessionStorage), providing a fallback value when no data is found or fails to parse.
|
|
119
|
+
|
|
120
|
+
It handles Server-Side Rendering (SSR) gracefully, allows dynamic storage keys, custom serialization/deserialization, custom storage providers, and optional synchronization across browser tabs via the storage event. It returns a StoredSignal<T> which includes a .clear() method and a reactive .key signal.
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { Component, effect, signal } from '@angular/core';
|
|
124
|
+
import { stored } from '@mmstack/primitives';
|
|
125
|
+
// import { FormsModule } from '@angular/forms'; // Needed for ngModel
|
|
126
|
+
|
|
127
|
+
@Component({
|
|
128
|
+
selector: 'app-theme-selector',
|
|
129
|
+
standalone: true,
|
|
130
|
+
// imports: [FormsModule], // Import if using ngModel
|
|
131
|
+
template: `
|
|
132
|
+
Theme:
|
|
133
|
+
<select [value]="theme()" (change)="theme.set($event.target.value)">
|
|
134
|
+
<option value="light">Light</option>
|
|
135
|
+
<option value="dark">Dark</option>
|
|
136
|
+
<option value="system">System</option>
|
|
137
|
+
</select>
|
|
138
|
+
<button (click)="theme.clear()">Reset Theme</button>
|
|
139
|
+
<p>Using storage key: {{ theme.key() }}</p>
|
|
140
|
+
`,
|
|
141
|
+
})
|
|
142
|
+
export class ThemeSelectorComponent {
|
|
143
|
+
// Persist theme preference in localStorage, default to 'system'
|
|
144
|
+
theme = stored<'light' | 'dark' | 'system'>('system', {
|
|
145
|
+
key: 'user-theme',
|
|
146
|
+
syncTabs: true, // Sync theme choice across tabs
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
constructor() {
|
|
150
|
+
effect(() => {
|
|
151
|
+
console.log(`Theme set to: ${this.theme()}`);
|
|
152
|
+
// Logic to apply theme (e.g., add class to body)
|
|
153
|
+
document.body.className = `theme-${this.theme()}`;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### mapArray
|
|
160
|
+
|
|
161
|
+
Reactive map helper that stabilizes a source array Signal by length. It provides stability by giving the mapping function a stable Signal<T> for each item based on its index. Sub signals are not re-created, rather they propagate value updates through. This is particularly useful for rendering lists (@for) as it minimizes DOM changes when array items change identity but represent the same conceptual entity.
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
import { Component, signal } from '@angular/core';
|
|
165
|
+
import { mapArray } from '@mmstack/primitives';
|
|
166
|
+
|
|
167
|
+
@Component({
|
|
168
|
+
selector: 'app-map-demo',
|
|
169
|
+
template: `
|
|
170
|
+
<ul>
|
|
171
|
+
@for (item of displayItems(); track item) {
|
|
172
|
+
<li>{{ item() }}</li>
|
|
173
|
+
}
|
|
174
|
+
</ul>
|
|
175
|
+
<button (click)="addItem()">Add</button>
|
|
176
|
+
<button (click)="updateFirst()">Update First</button>
|
|
177
|
+
`,
|
|
178
|
+
})
|
|
179
|
+
export class ListComponent {
|
|
180
|
+
sourceItems = signal([
|
|
181
|
+
{ id: 1, name: 'A' },
|
|
182
|
+
{ id: 2, name: 'B' },
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
readonly displayItems = mapArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
|
|
186
|
+
|
|
187
|
+
addItem() {
|
|
188
|
+
this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
updateFirst() {
|
|
192
|
+
this.sourceItems.update((items) => {
|
|
193
|
+
items[0] = { ...items[0], name: items[0].name + '+' };
|
|
194
|
+
return [...items]; // New array, but mapArray keeps stable signals
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### toWritable
|
|
201
|
+
|
|
202
|
+
A utility function that converts a read-only Signal into a WritableSignal by allowing you to provide custom implementations for the .set() and .update() methods. This is useful for creating controlled write access to signals that are naturally read-only (like those created by computed). This is used under the hood in derived.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
import { Component, signal, effect } from '@angular/core';
|
|
206
|
+
import { toWritable } from '@mmstack/primitives';
|
|
207
|
+
|
|
208
|
+
const user = signal({ name: 'John' });
|
|
209
|
+
|
|
210
|
+
const name = toWritable(
|
|
211
|
+
computed(() => user().name),
|
|
212
|
+
(name) => user.update((prev) => ({ ...prev, name })),
|
|
213
|
+
); // WritableSignal<string> bound to user signal
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### derived
|
|
217
|
+
|
|
218
|
+
Creates a WritableSignal that represents a part of another source WritableSignal (e.g., an object property or an array element), enabling two-way data binding. Changes to the source update the derived signal, and changes to the derived signal (via .set() or .update()) update the source signal accordingly.
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
const user = signal({ name: 'John' });
|
|
222
|
+
|
|
223
|
+
const name = derived(user, 'name'); // WritableSignal<string>, which updates user signal & reacts to changes in the name property
|
|
224
|
+
|
|
225
|
+
// Full syntax example
|
|
226
|
+
const name2 = derived(user, {
|
|
227
|
+
from: (u) => u.name,
|
|
228
|
+
onChange: (name) => user.update((prev) => ({ ...prev, name })),
|
|
229
|
+
});
|
|
230
|
+
```
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { untracked, signal, computed, isSignal, linkedSignal } from '@angular/core';
|
|
1
|
+
import { untracked, signal, computed, isSignal, linkedSignal, inject, PLATFORM_ID, isDevMode, effect, DestroyRef } from '@angular/core';
|
|
2
|
+
import { isPlatformServer } from '@angular/common';
|
|
3
|
+
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
@@ -36,38 +38,88 @@ function toWritable(signal, set, update) {
|
|
|
36
38
|
return internal;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Creates a `WritableSignal` whose publicly readable value is updated only after
|
|
43
|
+
* a specified debounce period (`ms`) has passed since the last call to its
|
|
44
|
+
* `.set()` or `.update()` method.
|
|
45
|
+
*
|
|
46
|
+
* This implementation avoids using `effect` by leveraging intermediate `computed`
|
|
47
|
+
* signals and a custom `equal` function to delay value propagation based on a timer.
|
|
48
|
+
*
|
|
49
|
+
* @template T The type of value the signal holds.
|
|
50
|
+
* @param initial The initial value of the signal.
|
|
51
|
+
* @param opt Options for signal creation, including:
|
|
52
|
+
* - `ms`: The debounce time in milliseconds. Defaults to 0 if omitted (no debounce).
|
|
53
|
+
* - Other `CreateSignalOptions` (like `equal`) are passed to underlying signals.
|
|
54
|
+
* @returns A `DebouncedSignal<T>` instance. Its readable value updates are debounced,
|
|
55
|
+
* and it includes an `.original` property providing immediate access to the latest set value.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { effect } from '@angular/core';
|
|
60
|
+
*
|
|
61
|
+
* // Create a debounced signal with a 500ms delay
|
|
62
|
+
* const query = debounced('', { ms: 500 });
|
|
63
|
+
*
|
|
64
|
+
* effect(() => {
|
|
65
|
+
* // This effect runs 500ms after the last change to 'query'
|
|
66
|
+
* console.log('Debounced Query:', query());
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* effect(() => {
|
|
70
|
+
* // This effect runs immediately when 'query.original' changes
|
|
71
|
+
* console.log('Original Query:', query.original());
|
|
72
|
+
* });
|
|
73
|
+
*
|
|
74
|
+
* console.log('Setting query to "a"');
|
|
75
|
+
* query.set('a');
|
|
76
|
+
* // Output: Original Query: a
|
|
77
|
+
*
|
|
78
|
+
* setTimeout(() => {
|
|
79
|
+
* console.log('Setting query to "ab"');
|
|
80
|
+
* query.set('ab');
|
|
81
|
+
* // Output: Original Query: ab
|
|
82
|
+
* }, 200); // Before debounce timeout
|
|
83
|
+
*
|
|
84
|
+
* setTimeout(() => {
|
|
85
|
+
* console.log('Setting query to "abc"');
|
|
86
|
+
* query.set('abc');
|
|
87
|
+
* // Output: Original Query: abc
|
|
88
|
+
* }, 400); // Before debounce timeout
|
|
89
|
+
*
|
|
90
|
+
* // ~500ms after the *last* set (at 400ms), the debounced effect runs:
|
|
91
|
+
* // Output (at ~900ms): Debounced Query: abc
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
function debounced(initial, opt) {
|
|
95
|
+
const internal = signal(initial, opt);
|
|
96
|
+
const ms = opt.ms ?? 0;
|
|
45
97
|
const trigger = signal(false);
|
|
46
|
-
|
|
98
|
+
let timeout;
|
|
47
99
|
const set = (value) => {
|
|
48
|
-
originalSet(value); // Update the *original* signal immediately
|
|
49
100
|
if (timeout)
|
|
50
101
|
clearTimeout(timeout);
|
|
102
|
+
internal.set(value);
|
|
51
103
|
timeout = setTimeout(() => {
|
|
52
|
-
trigger.update((
|
|
104
|
+
trigger.update((c) => !c);
|
|
53
105
|
}, ms);
|
|
54
106
|
};
|
|
55
|
-
// Update on the original signal, then trigger the debounced update
|
|
56
107
|
const update = (fn) => {
|
|
57
|
-
originalUpdate(fn); // Update the *original* signal immediately
|
|
58
108
|
if (timeout)
|
|
59
109
|
clearTimeout(timeout);
|
|
110
|
+
internal.update(fn);
|
|
60
111
|
timeout = setTimeout(() => {
|
|
61
|
-
trigger.update((
|
|
112
|
+
trigger.update((c) => !c);
|
|
62
113
|
}, ms);
|
|
63
114
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
})
|
|
70
|
-
writable
|
|
115
|
+
const stable = computed(() => ({
|
|
116
|
+
trigger: trigger(),
|
|
117
|
+
value: internal(),
|
|
118
|
+
}), {
|
|
119
|
+
equal: (a, b) => a.trigger === b.trigger,
|
|
120
|
+
});
|
|
121
|
+
const writable = toWritable(computed(() => stable().value, opt), set, update);
|
|
122
|
+
writable.original = internal;
|
|
71
123
|
return writable;
|
|
72
124
|
}
|
|
73
125
|
|
|
@@ -132,32 +184,84 @@ function isDerivation(sig) {
|
|
|
132
184
|
return 'from' in sig;
|
|
133
185
|
}
|
|
134
186
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Reactively maps items from a source array (or signal of an array) using a provided mapping function.
|
|
189
|
+
*
|
|
190
|
+
* This function serves a similar purpose to SolidJS's `mapArray` by providing stability
|
|
191
|
+
* for mapped items. It receives a source function returning an array (or a Signal<T[]>)
|
|
192
|
+
* and a mapping function.
|
|
193
|
+
*
|
|
194
|
+
* For each item in the source array, it creates a stable `computed` signal representing
|
|
195
|
+
* that item's value at its current index. This stable signal (`Signal<T>`) is passed
|
|
196
|
+
* to the mapping function. This ensures that downstream computations or components
|
|
197
|
+
* depending on the mapped result only re-render or re-calculate for the specific items
|
|
198
|
+
* that have changed, or when items are added/removed, rather than re-evaluating everything
|
|
199
|
+
* when the source array reference changes but items remain the same.
|
|
200
|
+
*
|
|
201
|
+
* It efficiently handles changes in the source array's length by reusing existing mapped
|
|
202
|
+
* results when possible, slicing when the array shrinks, and appending new mapped items
|
|
203
|
+
* when it grows.
|
|
204
|
+
*
|
|
205
|
+
* @template T The type of items in the source array.
|
|
206
|
+
* @template U The type of items in the resulting mapped array.
|
|
207
|
+
*
|
|
208
|
+
* @param source A function returning the source array `T[]`, or a `Signal<T[]>` itself.
|
|
209
|
+
* The `mapArray` function will reactively update based on changes to this source.
|
|
210
|
+
* @param map The mapping function. It is called for each item in the source array.
|
|
211
|
+
* It receives:
|
|
212
|
+
* - `value`: A stable `Signal<T>` representing the item at the current index.
|
|
213
|
+
* Use this signal within your mapping logic if you need reactivity
|
|
214
|
+
* tied to the specific item's value changes.
|
|
215
|
+
* - `index`: The number index of the item in the array.
|
|
216
|
+
* It should return the mapped value `U`.
|
|
217
|
+
* @param [opt] Optional `CreateSignalOptions<T>`. These options are passed directly
|
|
218
|
+
* to the `computed` signal created for each individual item (`Signal<T>`).
|
|
219
|
+
* This allows specifying options like a custom `equal` function for item comparison.
|
|
220
|
+
*
|
|
221
|
+
* @returns A `Signal<U[]>` containing the mapped array. This signal updates whenever
|
|
222
|
+
* the source array changes (either length or the values of its items).
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* const sourceItems = signal([
|
|
227
|
+
* { id: 1, name: 'Apple' },
|
|
228
|
+
* { id: 2, name: 'Banana' }
|
|
229
|
+
* ]);
|
|
230
|
+
*
|
|
231
|
+
* const mappedItems = mapArray(
|
|
232
|
+
* sourceItems,
|
|
233
|
+
* (itemSignal, index) => {
|
|
234
|
+
* // itemSignal is stable for a given item based on its index.
|
|
235
|
+
* // We create a computed here to react to changes in the item's name.
|
|
236
|
+
* return computed(() => `${index}: ${itemSignal().name.toUpperCase()}`);
|
|
237
|
+
* },
|
|
238
|
+
* // Example optional options (e.g., custom equality for item signals)
|
|
239
|
+
* { equal: (a, b) => a.id === b.id && a.name === b.name }
|
|
240
|
+
* );
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
function mapArray(source, map, opt) {
|
|
155
244
|
const data = isSignal(source) ? source : computed(source);
|
|
156
|
-
const
|
|
157
|
-
const reconciler = createReconciler(data, opt);
|
|
245
|
+
const len = computed(() => data().length);
|
|
158
246
|
return linkedSignal({
|
|
159
|
-
source: () =>
|
|
160
|
-
computation: (len, prev) =>
|
|
247
|
+
source: () => len(),
|
|
248
|
+
computation: (len, prev) => {
|
|
249
|
+
if (!prev)
|
|
250
|
+
return Array.from({ length: len }, (_, i) => map(computed(() => source()[i], opt), i));
|
|
251
|
+
if (len === prev.value.length)
|
|
252
|
+
return prev.value;
|
|
253
|
+
if (len < prev.value.length) {
|
|
254
|
+
return prev.value.slice(0, len);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
const next = [...prev.value];
|
|
258
|
+
for (let i = prev.value.length; i < len; i++) {
|
|
259
|
+
next[i] = map(computed(() => source()[i], opt), i);
|
|
260
|
+
}
|
|
261
|
+
return next;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
equal: (a, b) => a.length === b.length,
|
|
161
265
|
});
|
|
162
266
|
}
|
|
163
267
|
|
|
@@ -212,9 +316,195 @@ function isMutable(value) {
|
|
|
212
316
|
return 'mutate' in value && typeof value.mutate === 'function';
|
|
213
317
|
}
|
|
214
318
|
|
|
319
|
+
// Internal dummy store for server-side rendering
|
|
320
|
+
const noopStore = {
|
|
321
|
+
getItem: () => null,
|
|
322
|
+
setItem: () => {
|
|
323
|
+
/* noop */
|
|
324
|
+
},
|
|
325
|
+
removeItem: () => {
|
|
326
|
+
/* noop */
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* Creates a `WritableSignal` whose state is automatically synchronized with persistent storage
|
|
331
|
+
* (like `localStorage` or `sessionStorage`).
|
|
332
|
+
*
|
|
333
|
+
* It handles Server-Side Rendering (SSR) gracefully, allows dynamic storage keys,
|
|
334
|
+
* custom serialization/deserialization, custom storage providers, and optional
|
|
335
|
+
* synchronization across browser tabs.
|
|
336
|
+
*
|
|
337
|
+
* @template T The type of value held by the signal and stored (after serialization).
|
|
338
|
+
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
339
|
+
* or when deserialization fails. The signal's value will never be `null` or `undefined`
|
|
340
|
+
* publicly, it will always revert to this fallback.
|
|
341
|
+
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
342
|
+
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
343
|
+
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
344
|
+
* and a `.key` signal providing the current storage key.
|
|
345
|
+
*
|
|
346
|
+
* @remarks
|
|
347
|
+
* - **Persistence:** The signal automatically saves its value to storage whenever the signal's
|
|
348
|
+
* value or its configured `key` changes. This is managed internally using `effect`.
|
|
349
|
+
* - **SSR Safety:** Detects server environments and uses a no-op storage, preventing errors.
|
|
350
|
+
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
351
|
+
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
352
|
+
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
353
|
+
* using `DestroyRef`.
|
|
354
|
+
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
355
|
+
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
356
|
+
*
|
|
357
|
+
* @example
|
|
358
|
+
* ```ts
|
|
359
|
+
* import { Component, effect, signal } from '@angular/core';
|
|
360
|
+
* import { stored } from '@mmstack/primitives'; // Adjust import path
|
|
361
|
+
*
|
|
362
|
+
* @Component({
|
|
363
|
+
* selector: 'app-settings',
|
|
364
|
+
* standalone: true,
|
|
365
|
+
* template: `
|
|
366
|
+
* Theme:
|
|
367
|
+
* <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
|
|
368
|
+
* <option value="light">Light</option>
|
|
369
|
+
* <option value="dark">Dark</option>
|
|
370
|
+
* </select>
|
|
371
|
+
* <button (click)="theme.clear()">Clear Theme Setting</button>
|
|
372
|
+
* <p>Storage Key Used: {{ theme.key() }}</p>
|
|
373
|
+
* ` // Requires FormsModule for ngModel
|
|
374
|
+
* })
|
|
375
|
+
* export class SettingsComponent {
|
|
376
|
+
* theme = stored<'light' | 'dark'>('light', { key: 'app-theme', syncTabs: true });
|
|
377
|
+
* }
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, ...rest }) {
|
|
381
|
+
const isServer = isPlatformServer(inject(PLATFORM_ID));
|
|
382
|
+
const fallbackStore = isServer ? noopStore : localStorage;
|
|
383
|
+
const store = providedStore ?? fallbackStore;
|
|
384
|
+
const keySig = typeof key === 'string'
|
|
385
|
+
? computed(() => key)
|
|
386
|
+
: isSignal(key)
|
|
387
|
+
? key
|
|
388
|
+
: computed(key);
|
|
389
|
+
const getValue = (key) => {
|
|
390
|
+
const found = store.getItem(key);
|
|
391
|
+
if (found === null)
|
|
392
|
+
return null;
|
|
393
|
+
try {
|
|
394
|
+
return deserialize(found);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
if (isDevMode())
|
|
398
|
+
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const storeValue = (key, value) => {
|
|
403
|
+
try {
|
|
404
|
+
if (value === null)
|
|
405
|
+
return store.removeItem(key);
|
|
406
|
+
const serialized = serialize(value);
|
|
407
|
+
store.setItem(key, serialized);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
if (isDevMode())
|
|
411
|
+
console.error(`Failed to store value for key "${key}":`, err);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const opt = {
|
|
415
|
+
...rest,
|
|
416
|
+
equal,
|
|
417
|
+
};
|
|
418
|
+
const internal = signal(getValue(untracked(keySig)), {
|
|
419
|
+
...opt,
|
|
420
|
+
equal: (a, b) => {
|
|
421
|
+
if (a === null && b === null)
|
|
422
|
+
return true;
|
|
423
|
+
if (a === null || b === null)
|
|
424
|
+
return false;
|
|
425
|
+
return equal(a, b);
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
effect(() => storeValue(keySig(), internal()));
|
|
429
|
+
if (syncTabs && !isServer) {
|
|
430
|
+
const destroyRef = inject(DestroyRef);
|
|
431
|
+
const sync = (e) => {
|
|
432
|
+
if (e.key !== untracked(keySig))
|
|
433
|
+
return;
|
|
434
|
+
if (e.newValue === null)
|
|
435
|
+
internal.set(null);
|
|
436
|
+
else
|
|
437
|
+
internal.set(getValue(e.key));
|
|
438
|
+
};
|
|
439
|
+
window.addEventListener('storage', sync);
|
|
440
|
+
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
441
|
+
}
|
|
442
|
+
const writable = toWritable(computed(() => internal() ?? fallback, opt), internal.set);
|
|
443
|
+
writable.clear = () => {
|
|
444
|
+
internal.set(null);
|
|
445
|
+
};
|
|
446
|
+
writable.key = keySig;
|
|
447
|
+
return writable;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function withHistory(source, opt) {
|
|
451
|
+
const { equal = Object.is, debugName } = source[SIGNAL];
|
|
452
|
+
const maxSize = opt?.maxSize ?? Infinity;
|
|
453
|
+
const history = mutable([], opt);
|
|
454
|
+
const redoArray = mutable([]);
|
|
455
|
+
const set = (value) => {
|
|
456
|
+
const current = untracked(source);
|
|
457
|
+
if (equal(value, current))
|
|
458
|
+
return;
|
|
459
|
+
source.set(value);
|
|
460
|
+
history.mutate((c) => {
|
|
461
|
+
if (c.length >= maxSize) {
|
|
462
|
+
c = c.slice(Math.floor(maxSize / 2));
|
|
463
|
+
}
|
|
464
|
+
c.push(current);
|
|
465
|
+
return c;
|
|
466
|
+
});
|
|
467
|
+
redoArray.set([]);
|
|
468
|
+
};
|
|
469
|
+
const update = (updater) => {
|
|
470
|
+
set(updater(untracked(source)));
|
|
471
|
+
};
|
|
472
|
+
const internal = toWritable(computed(() => source(), {
|
|
473
|
+
equal,
|
|
474
|
+
debugName,
|
|
475
|
+
}), set, update);
|
|
476
|
+
internal.history = history;
|
|
477
|
+
internal.undo = () => {
|
|
478
|
+
const last = untracked(history);
|
|
479
|
+
if (last.length === 0)
|
|
480
|
+
return;
|
|
481
|
+
const prev = last.at(-1);
|
|
482
|
+
const cur = untracked(source);
|
|
483
|
+
history.inline((c) => c.pop());
|
|
484
|
+
redoArray.inline((c) => c.push(cur));
|
|
485
|
+
source.set(prev);
|
|
486
|
+
};
|
|
487
|
+
internal.redo = () => {
|
|
488
|
+
const last = untracked(redoArray);
|
|
489
|
+
if (last.length === 0)
|
|
490
|
+
return;
|
|
491
|
+
const prev = last.at(-1);
|
|
492
|
+
redoArray.inline((c) => c.pop());
|
|
493
|
+
set(prev);
|
|
494
|
+
};
|
|
495
|
+
internal.clear = () => {
|
|
496
|
+
history.set([]);
|
|
497
|
+
redoArray.set([]);
|
|
498
|
+
};
|
|
499
|
+
internal.canUndo = computed(() => history().length > 0);
|
|
500
|
+
internal.canRedo = computed(() => redoArray().length > 0);
|
|
501
|
+
internal.canClear = computed(() => internal.canUndo() || internal.canRedo());
|
|
502
|
+
return internal;
|
|
503
|
+
}
|
|
504
|
+
|
|
215
505
|
/**
|
|
216
506
|
* Generated bundle index. Do not edit.
|
|
217
507
|
*/
|
|
218
508
|
|
|
219
|
-
export { debounced, derived, isDerivation, isMutable, mapArray, mutable, toFakeDerivation, toFakeSignalDerivation, toWritable };
|
|
509
|
+
export { debounced, derived, isDerivation, isMutable, mapArray, mutable, stored, toFakeDerivation, toFakeSignalDerivation, toWritable, withHistory };
|
|
220
510
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|