@mmstack/primitives 19.1.0 → 19.1.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/LICENSE CHANGED
@@ -1,21 +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.
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,230 +1,239 @@
1
- # @mmstack/primitives
2
-
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
- [![npm version](https://badge.fury.io/js/%40mmstack%2Fprimitives.svg)](https://badge.fury.io/js/%40mmstack%2Fprimitives)
6
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ # @mmstack/primitives
2
+
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
+ [![npm version](https://badge.fury.io/js/%40mmstack%2Fprimitives.svg)](https://badge.fury.io/js/%40mmstack%2Fprimitives)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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, debounce } 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
+ You can also debounce an existing signal:
57
+
58
+ ```typescript
59
+ import { debounce } from '@mmstack/primitives';
60
+
61
+ const query = signal('');
62
+ const debouncedQuery = debounce(query, { ms: 300 });
63
+ ```
64
+
65
+ ### mutable
66
+
67
+ 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.
68
+
69
+ ```typescript
70
+ import { Component, computed, effect } from '@angular/core';
71
+ import { mutable } from '@mmstack/primitives';
72
+ import { FormsModule } from '@angular/forms';
73
+
74
+ @Component({
75
+ selector: 'app-mutable',
76
+ template: ` <button (click)="incrementAge()">inc</button> `,
77
+ })
78
+ export class SearchComponent {
79
+ user = mutable({ name: { first: 'John', last: 'Doe' }, age: 30 });
80
+
81
+ constructor() {
82
+ effect(() => {
83
+ // Runs every time user is mutated
84
+ console.log(this.user());
85
+ });
86
+
87
+ const age = computed(() => this.user().age);
88
+
89
+ effect(() => {
90
+ // Runs every time age changes
91
+ console.log(age());
92
+ });
93
+
94
+ const name = computed(() => this.user().name);
95
+ effect(() => {
96
+ // Doesnt run if user changes, unless name is destructured
97
+ console.log(name());
98
+ });
99
+
100
+ const name2 = computed(() => this.user().name, {
101
+ equal: () => false,
102
+ });
103
+
104
+ effect(() => {
105
+ // Runs every time user changes (even if name did not change)
106
+ console.log(name2());
107
+ });
108
+ }
109
+
110
+ incrementAge() {
111
+ user.mutate((prev) => {
112
+ prev.age++;
113
+ return prev;
114
+ });
115
+ }
116
+
117
+ incrementInline() {
118
+ user.inline((prev) => {
119
+ prev.age++;
120
+ });
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### stored
126
+
127
+ 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.
128
+
129
+ 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.
130
+
131
+ ```typescript
132
+ import { Component, effect, signal } from '@angular/core';
133
+ import { stored } from '@mmstack/primitives';
134
+ // import { FormsModule } from '@angular/forms'; // Needed for ngModel
135
+
136
+ @Component({
137
+ selector: 'app-theme-selector',
138
+ standalone: true,
139
+ // imports: [FormsModule], // Import if using ngModel
140
+ template: `
141
+ Theme:
142
+ <select [value]="theme()" (change)="theme.set($event.target.value)">
143
+ <option value="light">Light</option>
144
+ <option value="dark">Dark</option>
145
+ <option value="system">System</option>
146
+ </select>
147
+ <button (click)="theme.clear()">Reset Theme</button>
148
+ <p>Using storage key: {{ theme.key() }}</p>
149
+ `,
150
+ })
151
+ export class ThemeSelectorComponent {
152
+ // Persist theme preference in localStorage, default to 'system'
153
+ theme = stored<'light' | 'dark' | 'system'>('system', {
154
+ key: 'user-theme',
155
+ syncTabs: true, // Sync theme choice across tabs
156
+ });
157
+
158
+ constructor() {
159
+ effect(() => {
160
+ console.log(`Theme set to: ${this.theme()}`);
161
+ // Logic to apply theme (e.g., add class to body)
162
+ document.body.className = `theme-${this.theme()}`;
163
+ });
164
+ }
165
+ }
166
+ ```
167
+
168
+ ### mapArray
169
+
170
+ 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.
171
+
172
+ ```typescript
173
+ import { Component, signal } from '@angular/core';
174
+ import { mapArray } from '@mmstack/primitives';
175
+
176
+ @Component({
177
+ selector: 'app-map-demo',
178
+ template: `
179
+ <ul>
180
+ @for (item of displayItems(); track item) {
181
+ <li>{{ item() }}</li>
182
+ }
183
+ </ul>
184
+ <button (click)="addItem()">Add</button>
185
+ <button (click)="updateFirst()">Update First</button>
186
+ `,
187
+ })
188
+ export class ListComponent {
189
+ sourceItems = signal([
190
+ { id: 1, name: 'A' },
191
+ { id: 2, name: 'B' },
192
+ ]);
193
+
194
+ readonly displayItems = mapArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
195
+
196
+ addItem() {
197
+ this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
198
+ }
199
+
200
+ updateFirst() {
201
+ this.sourceItems.update((items) => {
202
+ items[0] = { ...items[0], name: items[0].name + '+' };
203
+ return [...items]; // New array, but mapArray keeps stable signals
204
+ });
205
+ }
206
+ }
207
+ ```
208
+
209
+ ### toWritable
210
+
211
+ 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.
212
+
213
+ ```typescript
214
+ import { Component, signal, effect } from '@angular/core';
215
+ import { toWritable } from '@mmstack/primitives';
216
+
217
+ const user = signal({ name: 'John' });
218
+
219
+ const name = toWritable(
220
+ computed(() => user().name),
221
+ (name) => user.update((prev) => ({ ...prev, name })),
222
+ ); // WritableSignal<string> bound to user signal
223
+ ```
224
+
225
+ ### derived
226
+
227
+ 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.
228
+
229
+ ```typescript
230
+ const user = signal({ name: 'John' });
231
+
232
+ const name = derived(user, 'name'); // WritableSignal<string>, which updates user signal & reacts to changes in the name property
233
+
234
+ // Full syntax example
235
+ const name2 = derived(user, {
236
+ from: (u) => u.name,
237
+ onChange: (name) => user.update((prev) => ({ ...prev, name })),
238
+ });
239
+ ```