@signaltree/ng-forms 7.1.4 β 7.3.1
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 +192 -75
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,81 +1,132 @@
|
|
|
1
1
|
# @signaltree/ng-forms
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Angular FormGroup bridge for SignalTree's `form()` marker**. Adds reactive forms integration, conditional fields, and undo/redo to tree-integrated forms.
|
|
4
4
|
|
|
5
5
|
**Bundle size: 3.38KB gzipped**
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Architecture: form() + formBridge()
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
SignalTree v7 introduces a layered forms architecture:
|
|
10
10
|
|
|
11
|
-
### **π² Tree-Structured State**
|
|
12
|
-
```typescript
|
|
13
|
-
// Angular 21: Flat fields, no relationships
|
|
14
|
-
const name = formField('');
|
|
15
|
-
const email = formField('');
|
|
16
|
-
|
|
17
|
-
// ng-forms: Hierarchical structure that mirrors your data model
|
|
18
|
-
const form = createFormTree({
|
|
19
|
-
user: { name: '', email: '' },
|
|
20
|
-
address: { street: '', city: '', zip: '' }
|
|
21
|
-
});
|
|
22
|
-
// Access nested: form.$.user.name()
|
|
23
|
-
// Validate paths: 'address.zip'
|
|
24
11
|
```
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
12
|
+
@signaltree/core @signaltree/ng-forms
|
|
13
|
+
βββββββββββββββββββββββββββ βββββββββββββββββββββββββββ
|
|
14
|
+
β form() marker β β formBridge() β
|
|
15
|
+
β βββββββββββββββββββββββ β ββββΊ β enhancer that: β
|
|
16
|
+
β β’ Signal-based fields β β β’ Creates FormGroup β
|
|
17
|
+
β β’ Sync/async validators β β β’ Bidirectional sync β
|
|
18
|
+
β β’ Persistence β β β’ Conditional fields β
|
|
19
|
+
β β’ Wizard navigation β β β’ Angular validators β
|
|
20
|
+
β β’ dirty/valid/submittingβ β β
|
|
21
|
+
βββββββββββββββββββββββββββ β withFormHistory() β
|
|
22
|
+
Works standalone! β β’ Undo/redo β
|
|
23
|
+
βββββββββββββββββββββββββββ
|
|
36
24
|
```
|
|
37
25
|
|
|
38
|
-
|
|
26
|
+
**Key insight**: `form()` is self-sufficient. `formBridge()` adds Angular-specific capabilities.
|
|
27
|
+
|
|
28
|
+
## Quick Start (Recommended Pattern)
|
|
29
|
+
|
|
39
30
|
```typescript
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
import { signalTree, form } from '@signaltree/core';
|
|
32
|
+
import { formBridge } from '@signaltree/ng-forms';
|
|
33
|
+
|
|
34
|
+
// Define forms in your tree
|
|
35
|
+
const tree = signalTree({
|
|
36
|
+
checkout: {
|
|
37
|
+
shipping: form({
|
|
38
|
+
initial: { name: '', address: '', zip: '' },
|
|
39
|
+
validators: {
|
|
40
|
+
zip: (v) => (/^\d{5}$/.test(String(v)) ? null : 'Invalid ZIP'),
|
|
41
|
+
},
|
|
42
|
+
persist: 'checkout-shipping',
|
|
43
|
+
}),
|
|
44
|
+
payment: form({
|
|
45
|
+
initial: { card: '', cvv: '' },
|
|
46
|
+
wizard: { steps: ['card', 'review'] },
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
}).with(
|
|
50
|
+
formBridge({
|
|
51
|
+
conditionals: [{ when: (v) => v.checkout.sameAsBilling, fields: ['checkout.shipping.*'] }],
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Use in components
|
|
56
|
+
@Component({
|
|
57
|
+
template: `
|
|
58
|
+
<!-- Option 1: Use form() signals directly -->
|
|
59
|
+
<input [value]="tree.$.checkout.shipping.$.name()" (input)="tree.$.checkout.shipping.$.name.set($event.target.value)" />
|
|
60
|
+
|
|
61
|
+
<!-- Option 2: Use Angular FormGroup -->
|
|
62
|
+
<form [formGroup]="shippingForm">
|
|
63
|
+
<input formControlName="name" />
|
|
64
|
+
</form>
|
|
65
|
+
`,
|
|
66
|
+
})
|
|
67
|
+
class CheckoutComponent {
|
|
68
|
+
tree = inject(CHECKOUT_TREE);
|
|
69
|
+
|
|
70
|
+
// Get the FormGroup bridge
|
|
71
|
+
shippingForm = this.tree.getAngularForm('checkout.shipping')?.formGroup;
|
|
72
|
+
}
|
|
47
73
|
```
|
|
48
74
|
|
|
49
|
-
|
|
75
|
+
## When to Use Each Layer
|
|
76
|
+
|
|
77
|
+
### form() alone (no ng-forms needed)
|
|
78
|
+
|
|
50
79
|
```typescript
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
import { signalTree, form } from '@signaltree/core';
|
|
81
|
+
import { email } from '@signaltree/ng-forms';
|
|
82
|
+
|
|
83
|
+
// Pure signal forms - works without Angular forms module
|
|
84
|
+
const tree = signalTree({
|
|
85
|
+
login: form({
|
|
86
|
+
initial: { email: '', password: '' },
|
|
87
|
+
validators: { email: email() },
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Full functionality without Angular FormGroup
|
|
92
|
+
tree.$.login.$.email.set('user@test.com');
|
|
93
|
+
tree.$.login.valid(); // Reactive validation
|
|
94
|
+
tree.$.login.validate(); // Trigger validation
|
|
95
|
+
tree.$.login.submit(fn); // Submit handling
|
|
96
|
+
tree.$.login.wizard?.next(); // Wizard navigation (if configured)
|
|
56
97
|
```
|
|
57
98
|
|
|
58
|
-
|
|
99
|
+
**Use when**: SSR, unit tests, simple forms, non-Angular environments
|
|
100
|
+
|
|
101
|
+
### form() + formBridge()
|
|
102
|
+
|
|
59
103
|
```typescript
|
|
60
|
-
// Angular
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
104
|
+
// Add Angular FormGroup bridge
|
|
105
|
+
const tree = signalTree({
|
|
106
|
+
profile: form({ initial: { name: '' } }),
|
|
107
|
+
}).with(formBridge());
|
|
108
|
+
|
|
109
|
+
// Now you get FormGroup access
|
|
110
|
+
const formGroup = tree.getAngularForm('profile')?.formGroup;
|
|
111
|
+
// Or attached directly: (tree.$.profile as any).formGroup
|
|
66
112
|
```
|
|
67
113
|
|
|
68
|
-
|
|
114
|
+
**Use when**: Need `[formGroup]` directives, Angular validators, conditional field disabling
|
|
115
|
+
|
|
116
|
+
### form() + formBridge() + withFormHistory()
|
|
117
|
+
|
|
69
118
|
```typescript
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
119
|
+
const tree = signalTree({
|
|
120
|
+
editor: form({ initial: { content: '' } }),
|
|
121
|
+
})
|
|
122
|
+
.with(formBridge())
|
|
123
|
+
.with(withFormHistory({ capacity: 50 }));
|
|
124
|
+
|
|
125
|
+
tree.undo();
|
|
126
|
+
tree.redo();
|
|
76
127
|
```
|
|
77
128
|
|
|
78
|
-
**Use
|
|
129
|
+
**Use when**: Complex editors, need undo/redo
|
|
79
130
|
|
|
80
131
|
## Installation
|
|
81
132
|
|
|
@@ -89,9 +140,9 @@ pnpm add @signaltree/core @signaltree/ng-forms
|
|
|
89
140
|
|
|
90
141
|
```typescript
|
|
91
142
|
import { Component } from '@angular/core';
|
|
92
|
-
import { createFormTree,
|
|
143
|
+
import { createFormTree, required, email } from '@signaltree/ng-forms';
|
|
93
144
|
|
|
94
|
-
interface ProfileForm {
|
|
145
|
+
interface ProfileForm extends Record<string, unknown> {
|
|
95
146
|
name: string;
|
|
96
147
|
email: string;
|
|
97
148
|
marketing: boolean;
|
|
@@ -134,9 +185,9 @@ export class ProfileFormComponent {
|
|
|
134
185
|
persistKey: 'profile-form',
|
|
135
186
|
storage: this.storage,
|
|
136
187
|
fieldConfigs: {
|
|
137
|
-
name: { validators:
|
|
188
|
+
name: { validators: [required('Name is required')] },
|
|
138
189
|
email: {
|
|
139
|
-
validators: [
|
|
190
|
+
validators: [required(), email()],
|
|
140
191
|
debounceMs: 150,
|
|
141
192
|
},
|
|
142
193
|
},
|
|
@@ -175,11 +226,13 @@ The returned `FormTree` exposes:
|
|
|
175
226
|
**ng-forms complements Angular 21's native signal forms**βuse both in the same app:
|
|
176
227
|
|
|
177
228
|
### Use Angular 21 `FormField<T>` for:
|
|
229
|
+
|
|
178
230
|
- β
Simple, flat forms (login, search)
|
|
179
231
|
- β
Single-field validation
|
|
180
232
|
- β
Maximum type safety
|
|
181
233
|
|
|
182
234
|
### Use ng-forms `createFormTree()` for:
|
|
235
|
+
|
|
183
236
|
- β
Nested object structures (user + address + payment)
|
|
184
237
|
- β
Forms with persistence/auto-save
|
|
185
238
|
- β
Wizard/multi-step flows
|
|
@@ -188,6 +241,7 @@ The returned `FormTree` exposes:
|
|
|
188
241
|
- β
Migration from reactive forms
|
|
189
242
|
|
|
190
243
|
### Hybrid Example: Simple Fields + Complex Tree
|
|
244
|
+
|
|
191
245
|
```typescript
|
|
192
246
|
import { formField } from '@angular/forms';
|
|
193
247
|
import { createFormTree } from '@signaltree/ng-forms';
|
|
@@ -205,8 +259,8 @@ class CheckoutComponent {
|
|
|
205
259
|
}, {
|
|
206
260
|
persistKey: 'checkout-draft',
|
|
207
261
|
fieldConfigs: {
|
|
208
|
-
'shipping.zip': { validators:
|
|
209
|
-
'payment.card': { validators:
|
|
262
|
+
'shipping.zip': { validators: [(v) => /^\d{5}$/.test(String(v)) ? null : 'Invalid ZIP'] },
|
|
263
|
+
'payment.card': { validators: [(v) => /^\d{13,19}$/.test(String(v)) ? null : 'Invalid card'], debounceMs: 300 }
|
|
210
264
|
}
|
|
211
265
|
});
|
|
212
266
|
|
|
@@ -215,6 +269,7 @@ class CheckoutComponent {
|
|
|
215
269
|
```
|
|
216
270
|
|
|
217
271
|
### Connecting to Reactive Forms
|
|
272
|
+
|
|
218
273
|
```ts
|
|
219
274
|
import { toWritableSignal } from '@signaltree/core';
|
|
220
275
|
|
|
@@ -235,7 +290,7 @@ const checkout = createFormTree(initialState, {
|
|
|
235
290
|
},
|
|
236
291
|
fieldConfigs: {
|
|
237
292
|
'payment.card.number': { debounceMs: 200 },
|
|
238
|
-
'preferences.*': { validators:
|
|
293
|
+
'preferences.*': { validators: [required()] },
|
|
239
294
|
},
|
|
240
295
|
conditionals: [
|
|
241
296
|
{
|
|
@@ -325,21 +380,83 @@ Use `SignalValueDirective` to keep standalone signals and `ngModel` fields align
|
|
|
325
380
|
|
|
326
381
|
## When to use ng-forms vs Angular 21 signal forms
|
|
327
382
|
|
|
328
|
-
| Scenario
|
|
329
|
-
|
|
330
|
-
| Login form (2-3 fields)
|
|
331
|
-
| Search bar with filters
|
|
332
|
-
| User profile with nested address
|
|
333
|
-
| Checkout flow (shipping + payment + items) | β
**ng-forms** (persistence + wizard)
|
|
334
|
-
| Multi-step onboarding (5+ steps)
|
|
335
|
-
| Form with auto-save drafts
|
|
336
|
-
| Complex editor with undo/redo
|
|
337
|
-
| Migrating from reactive forms
|
|
338
|
-
| Dynamic form with conditional fields
|
|
339
|
-
| Form synced with global app state
|
|
383
|
+
| Scenario | Recommendation |
|
|
384
|
+
| ------------------------------------------ | ---------------------------------------- |
|
|
385
|
+
| Login form (2-3 fields) | β
Angular 21 `FormField` |
|
|
386
|
+
| Search bar with filters | β
Angular 21 `FormField` |
|
|
387
|
+
| User profile with nested address | β
**ng-forms** (tree structure) |
|
|
388
|
+
| Checkout flow (shipping + payment + items) | β
**ng-forms** (persistence + wizard) |
|
|
389
|
+
| Multi-step onboarding (5+ steps) | β
**ng-forms** (wizard API) |
|
|
390
|
+
| Form with auto-save drafts | β
**ng-forms** (built-in persistence) |
|
|
391
|
+
| Complex editor with undo/redo | β
**ng-forms** (history tracking) |
|
|
392
|
+
| Migrating from reactive forms | β
**ng-forms** (FormGroup bridge) |
|
|
393
|
+
| Dynamic form with conditional fields | β
**ng-forms** (conditionals config) |
|
|
394
|
+
| Form synced with global app state | β
**ng-forms** (SignalTree integration) |
|
|
340
395
|
|
|
341
396
|
**Rule of thumb**: If your form data is a nested object or needs workflow features (persistence/wizards/history), use ng-forms. For simple flat forms, Angular 21's native signal forms are perfect.
|
|
342
397
|
|
|
398
|
+
## Migration from createFormTree()
|
|
399
|
+
|
|
400
|
+
`createFormTree()` is deprecated in favor of the composable `form()` + `formBridge()` pattern.
|
|
401
|
+
|
|
402
|
+
### Before (deprecated)
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
import { createFormTree, email } from '@signaltree/ng-forms';
|
|
406
|
+
|
|
407
|
+
const form = createFormTree(
|
|
408
|
+
{
|
|
409
|
+
name: '',
|
|
410
|
+
email: '',
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
validators: { email: email() },
|
|
414
|
+
persistKey: 'profile-form',
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Access
|
|
419
|
+
form.$.name.set('John');
|
|
420
|
+
form.form; // FormGroup
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### After (recommended)
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
import { signalTree, form } from '@signaltree/core';
|
|
427
|
+
import { formBridge, email } from '@signaltree/ng-forms';
|
|
428
|
+
|
|
429
|
+
const tree = signalTree({
|
|
430
|
+
profile: form({
|
|
431
|
+
initial: { name: '', email: '' },
|
|
432
|
+
validators: { email: email() },
|
|
433
|
+
persist: 'profile-form',
|
|
434
|
+
}),
|
|
435
|
+
}).with(formBridge());
|
|
436
|
+
|
|
437
|
+
// Access
|
|
438
|
+
tree.$.profile.$.name.set('John');
|
|
439
|
+
tree.getAngularForm('profile')?.formGroup; // FormGroup
|
|
440
|
+
// Or: (tree.$.profile as any).formGroup
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Key differences
|
|
444
|
+
|
|
445
|
+
| Aspect | createFormTree() | form() + formBridge() |
|
|
446
|
+
| -------------------- | ----------------------- | ---------------------------- |
|
|
447
|
+
| **Standalone** | Always needs Angular | form() works without Angular |
|
|
448
|
+
| **Tree integration** | Separate from app state | Lives in your main tree |
|
|
449
|
+
| **DevTools** | Separate | Inherits tree DevTools |
|
|
450
|
+
| **Composability** | Limited | Add enhancers freely |
|
|
451
|
+
| **Tree-shaking** | All-or-nothing | Only what you use |
|
|
452
|
+
|
|
453
|
+
### Migration steps
|
|
454
|
+
|
|
455
|
+
1. Move form state into your SignalTree using `form()` marker
|
|
456
|
+
2. Add `.with(formBridge())` to your tree
|
|
457
|
+
3. Update access patterns: `form.$.field` β `tree.$.formName.$.field`
|
|
458
|
+
4. Update FormGroup access: `form.form` β `tree.getAngularForm('path')?.formGroup`
|
|
459
|
+
|
|
343
460
|
## Links
|
|
344
461
|
|
|
345
462
|
- [SignalTree Documentation](https://signaltree.io)
|
package/package.json
CHANGED