@signaltree/ng-forms 3.0.1 → 4.0.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/README.md +140 -804
- package/fesm2022/signaltree-ng-forms-src-audit.mjs +20 -0
- package/fesm2022/signaltree-ng-forms-src-audit.mjs.map +1 -0
- package/fesm2022/signaltree-ng-forms-src-history.mjs +113 -0
- package/fesm2022/signaltree-ng-forms-src-history.mjs.map +1 -0
- package/fesm2022/signaltree-ng-forms-src-rxjs.mjs +21 -0
- package/fesm2022/signaltree-ng-forms-src-rxjs.mjs.map +1 -0
- package/fesm2022/signaltree-ng-forms.mjs +911 -273
- package/fesm2022/signaltree-ng-forms.mjs.map +1 -1
- package/index.d.ts +74 -31
- package/package.json +20 -7
- package/src/audit/index.d.ts +15 -0
- package/src/history/index.d.ts +24 -0
- package/src/rxjs/index.d.ts +6 -0
package/README.md
CHANGED
|
@@ -1,884 +1,220 @@
|
|
|
1
1
|
# @signaltree/ng-forms
|
|
2
2
|
|
|
3
|
-
Angular
|
|
3
|
+
Angular 20 signal forms meet SignalTree. `@signaltree/ng-forms` keeps your form state, validation, persistence, and wizard flows in sync with the rest of your application signals—no manual plumbing.
|
|
4
4
|
|
|
5
5
|
**Bundle size: 3.38KB gzipped**
|
|
6
6
|
|
|
7
|
-
## What is @signaltree/ng-forms?
|
|
8
|
-
|
|
9
|
-
The ng-forms package provides deep Angular Forms integration:
|
|
10
|
-
|
|
11
|
-
- **Reactive Forms binding** with automatic synchronization
|
|
12
|
-
- **Template-driven forms** support with signals
|
|
13
|
-
- **Advanced validation** with real-time feedback
|
|
14
|
-
- **Form state management** (dirty, touched, valid states)
|
|
15
|
-
- **Dynamic form generation** from SignalTree state
|
|
16
|
-
- **Cross-field validation** and complex form logic
|
|
17
|
-
|
|
18
7
|
## Installation
|
|
19
8
|
|
|
20
9
|
```bash
|
|
21
|
-
|
|
10
|
+
pnpm add @signaltree/core @signaltree/ng-forms
|
|
22
11
|
```
|
|
23
12
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
```typescript
|
|
27
|
-
import { signalTree } from '@signaltree/core';
|
|
28
|
-
import { withForms } from '@signaltree/ng-forms';
|
|
29
|
-
|
|
30
|
-
const tree = signalTree({
|
|
31
|
-
user: {
|
|
32
|
-
name: '',
|
|
33
|
-
email: '',
|
|
34
|
-
age: 0,
|
|
35
|
-
},
|
|
36
|
-
preferences: {
|
|
37
|
-
newsletter: false,
|
|
38
|
-
theme: 'light',
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// Automatic form generation
|
|
43
|
-
const userForm = tree.createForm('user');
|
|
44
|
-
const preferencesForm = tree.createForm('preferences');
|
|
45
|
-
|
|
46
|
-
// Forms automatically sync with SignalTree state
|
|
47
|
-
```
|
|
13
|
+
> The package targets Angular 20.3+ and TypeScript 5.5+. For Angular 17–19 support use the 0.10.x line.
|
|
48
14
|
|
|
49
|
-
##
|
|
50
|
-
|
|
51
|
-
### Reactive Forms Integration
|
|
15
|
+
## Quick start
|
|
52
16
|
|
|
53
17
|
```typescript
|
|
54
18
|
import { Component } from '@angular/core';
|
|
55
|
-
import {
|
|
19
|
+
import { createFormTree, validators } from '@signaltree/ng-forms';
|
|
20
|
+
|
|
21
|
+
interface ProfileForm {
|
|
22
|
+
name: string;
|
|
23
|
+
email: string;
|
|
24
|
+
marketing: boolean;
|
|
25
|
+
}
|
|
56
26
|
|
|
57
27
|
@Component({
|
|
28
|
+
selector: 'app-profile-form',
|
|
58
29
|
template: `
|
|
59
|
-
<form [formGroup]="
|
|
60
|
-
<!-- Form automatically synced with SignalTree -->
|
|
30
|
+
<form [formGroup]="profile.form" (ngSubmit)="save()">
|
|
61
31
|
<input formControlName="name" placeholder="Name" />
|
|
32
|
+
<span class="error" *ngIf="profile.getFieldError('name')()">
|
|
33
|
+
{{ profile.getFieldError('name')() }}
|
|
34
|
+
</span>
|
|
35
|
+
|
|
62
36
|
<input formControlName="email" placeholder="Email" />
|
|
63
|
-
<
|
|
37
|
+
<span class="error" *ngIf="profile.getFieldError('email')()">
|
|
38
|
+
{{ profile.getFieldError('email')() }}
|
|
39
|
+
</span>
|
|
64
40
|
|
|
65
|
-
<
|
|
41
|
+
<label> <input type="checkbox" formControlName="marketing" /> Email marketing </label>
|
|
42
|
+
|
|
43
|
+
<button type="submit" [disabled]="profile.valid() === false">
|
|
44
|
+
{{ profile.submitting() ? 'Saving...' : 'Save profile' }}
|
|
45
|
+
</button>
|
|
66
46
|
</form>
|
|
67
47
|
|
|
68
|
-
|
|
69
|
-
<div>
|
|
70
|
-
<p>Current State: {{ tree.$.user() | json }}</p>
|
|
71
|
-
<p>Form Valid: {{ userForm.valid }}</p>
|
|
72
|
-
<p>Form Dirty: {{ userForm.dirty }}</p>
|
|
73
|
-
</div>
|
|
48
|
+
<pre>Signals: {{ profile.$.name() }} / {{ profile.$.email() }}</pre>
|
|
74
49
|
`,
|
|
75
50
|
})
|
|
76
|
-
class
|
|
77
|
-
|
|
78
|
-
user: {
|
|
79
|
-
name: '',
|
|
80
|
-
email: '',
|
|
81
|
-
age: 0,
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// Create form with automatic binding
|
|
86
|
-
userForm = this.tree.createForm('user', {
|
|
87
|
-
name: ['', [Validators.required, Validators.minLength(2)]],
|
|
88
|
-
email: ['', [Validators.required, Validators.email]],
|
|
89
|
-
age: [0, [Validators.required, Validators.min(18)]],
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
onSubmit() {
|
|
93
|
-
if (this.userForm.valid) {
|
|
94
|
-
console.log('Form Data:', this.tree.$.user());
|
|
95
|
-
console.log('Form Value:', this.userForm.value);
|
|
96
|
-
// Both are automatically synchronized!
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
```
|
|
51
|
+
export class ProfileFormComponent {
|
|
52
|
+
private storage = typeof window !== 'undefined' ? window.localStorage : undefined;
|
|
101
53
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
@Component({
|
|
106
|
-
template: `
|
|
107
|
-
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
|
|
108
|
-
<!-- Bind directly to SignalTree signals -->
|
|
109
|
-
<input name="name" ngModel [ngModel]="tree.$.user.name()" (ngModelChange)="tree.$.user.name.set($event)" #name="ngModel" required minlength="2" />
|
|
110
|
-
<div *ngIf="name.invalid && name.touched">Name is required (min 2 characters)</div>
|
|
111
|
-
|
|
112
|
-
<input name="email" type="email" ngModel [ngModel]="tree.$.user.email()" (ngModelChange)="tree.$.user.email.set($event)" #email="ngModel" required email />
|
|
113
|
-
<div *ngIf="email.invalid && email.touched">Valid email is required</div>
|
|
114
|
-
|
|
115
|
-
<button type="submit" [disabled]="userForm.invalid">Submit</button>
|
|
116
|
-
</form>
|
|
117
|
-
`,
|
|
118
|
-
})
|
|
119
|
-
class TemplateFormComponent {
|
|
120
|
-
tree = signalTree({
|
|
121
|
-
user: {
|
|
54
|
+
profile = createFormTree<ProfileForm>(
|
|
55
|
+
{
|
|
122
56
|
name: '',
|
|
123
57
|
email: '',
|
|
58
|
+
marketing: false,
|
|
124
59
|
},
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
60
|
+
{
|
|
61
|
+
persistKey: 'profile-form',
|
|
62
|
+
storage: this.storage,
|
|
63
|
+
fieldConfigs: {
|
|
64
|
+
name: { validators: validators.required('Name is required') },
|
|
65
|
+
email: {
|
|
66
|
+
validators: [validators.required(), validators.email()],
|
|
67
|
+
debounceMs: 150,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
130
70
|
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
### Advanced Validation
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
const tree = signalTree({
|
|
139
|
-
registration: {
|
|
140
|
-
username: '',
|
|
141
|
-
email: '',
|
|
142
|
-
password: '',
|
|
143
|
-
confirmPassword: '',
|
|
144
|
-
agreeToTerms: false,
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// Custom validators that can access SignalTree state
|
|
149
|
-
const usernameAsyncValidator = (control: AbstractControl) => {
|
|
150
|
-
return of(control.value).with(
|
|
151
|
-
delay(300), // Debounce
|
|
152
|
-
switchMap((username) =>
|
|
153
|
-
// Check if username exists (can access tree state)
|
|
154
|
-
this.userService.checkUsernameAvailability(username)
|
|
155
|
-
),
|
|
156
|
-
map((isAvailable) => (isAvailable ? null : { usernameTaken: true }))
|
|
157
71
|
);
|
|
158
|
-
};
|
|
159
72
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const registrationForm = tree.createForm(
|
|
167
|
-
'registration',
|
|
168
|
-
{
|
|
169
|
-
username: [
|
|
170
|
-
'',
|
|
171
|
-
{
|
|
172
|
-
validators: [Validators.required, Validators.minLength(3)],
|
|
173
|
-
asyncValidators: [usernameAsyncValidator],
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
email: ['', [Validators.required, Validators.email]],
|
|
177
|
-
password: ['', [Validators.required, Validators.minLength(8)]],
|
|
178
|
-
confirmPassword: ['', Validators.required],
|
|
179
|
-
agreeToTerms: [false, Validators.requiredTrue],
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
validators: [passwordMatchValidator], // Form-level validator
|
|
183
|
-
}
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
// Real-time validation feedback
|
|
187
|
-
@Component({
|
|
188
|
-
template: `
|
|
189
|
-
<form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">
|
|
190
|
-
<div class="field">
|
|
191
|
-
<input formControlName="username" placeholder="Username" />
|
|
192
|
-
<div class="validation-messages">
|
|
193
|
-
@if (registrationForm.get('username')?.hasError('required')) {
|
|
194
|
-
<span class="error">Username is required</span>
|
|
195
|
-
} @if (registrationForm.get('username')?.hasError('minlength')) {
|
|
196
|
-
<span class="error">Username must be at least 3 characters</span>
|
|
197
|
-
} @if (registrationForm.get('username')?.hasError('usernameTaken')) {
|
|
198
|
-
<span class="error">Username is already taken</span>
|
|
199
|
-
} @if (registrationForm.get('username')?.pending) {
|
|
200
|
-
<span class="checking">Checking availability...</span>
|
|
201
|
-
}
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
|
|
205
|
-
<div class="field">
|
|
206
|
-
<input formControlName="password" type="password" placeholder="Password" />
|
|
207
|
-
<div class="validation-messages">
|
|
208
|
-
@if (registrationForm.get('password')?.hasError('required')) {
|
|
209
|
-
<span class="error">Password is required</span>
|
|
210
|
-
} @if (registrationForm.get('password')?.hasError('minlength')) {
|
|
211
|
-
<span class="error">Password must be at least 8 characters</span>
|
|
212
|
-
}
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
215
|
-
|
|
216
|
-
<div class="field">
|
|
217
|
-
<input formControlName="confirmPassword" type="password" placeholder="Confirm Password" />
|
|
218
|
-
<div class="validation-messages">
|
|
219
|
-
@if (registrationForm.hasError('passwordMismatch')) {
|
|
220
|
-
<span class="error">Passwords don't match</span>
|
|
221
|
-
}
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
<label>
|
|
226
|
-
<input formControlName="agreeToTerms" type="checkbox" />
|
|
227
|
-
I agree to the terms and conditions
|
|
228
|
-
</label>
|
|
229
|
-
|
|
230
|
-
<button type="submit" [disabled]="registrationForm.invalid">Register</button>
|
|
231
|
-
</form>
|
|
232
|
-
`,
|
|
233
|
-
})
|
|
234
|
-
class RegistrationComponent {
|
|
235
|
-
registrationForm = registrationForm;
|
|
236
|
-
|
|
237
|
-
onSubmit() {
|
|
238
|
-
if (this.registrationForm.valid) {
|
|
239
|
-
console.log('Registration Data:', tree.$.registration());
|
|
240
|
-
}
|
|
73
|
+
async save() {
|
|
74
|
+
await this.profile.submit(async (values) => {
|
|
75
|
+
// Persist values to your API or service layer here
|
|
76
|
+
console.log('Saving profile', values);
|
|
77
|
+
});
|
|
241
78
|
}
|
|
242
79
|
}
|
|
243
80
|
```
|
|
244
81
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
```typescript
|
|
248
|
-
interface FormField {
|
|
249
|
-
name: string;
|
|
250
|
-
type: 'text' | 'email' | 'number' | 'checkbox' | 'select';
|
|
251
|
-
label: string;
|
|
252
|
-
required?: boolean;
|
|
253
|
-
options?: Array<{ value: any; label: string }>;
|
|
254
|
-
validators?: any[];
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const tree = signalTree({
|
|
258
|
-
dynamicData: {} as Record<string, any>,
|
|
259
|
-
formConfig: {
|
|
260
|
-
fields: [] as FormField[],
|
|
261
|
-
},
|
|
262
|
-
});
|
|
82
|
+
The returned `FormTree` exposes:
|
|
263
83
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
84
|
+
- `form`: Angular `FormGroup` for templates and directives
|
|
85
|
+
- `$` / `state`: signal-backed access to individual fields
|
|
86
|
+
- `errors`, `asyncErrors`, `valid`, `dirty`, `submitting`: writable signals for UI state
|
|
87
|
+
- Helpers such as `setValue`, `setValues`, `reset`, `validate`, and `submit`
|
|
268
88
|
|
|
269
|
-
|
|
270
|
-
const validators = [];
|
|
271
|
-
if (field.required) validators.push(Validators.required);
|
|
272
|
-
if (field.type === 'email') validators.push(Validators.email);
|
|
89
|
+
## Core capabilities
|
|
273
90
|
|
|
274
|
-
|
|
275
|
-
|
|
91
|
+
- **Signal-synced forms**: Bidirectional sync between Angular FormControls and SignalTree signals
|
|
92
|
+
- **Per-field configuration**: Debounce, sync & async validators, and wildcard matcher support
|
|
93
|
+
- **Conditional fields**: Enable/disable controls based on dynamic predicates
|
|
94
|
+
- **Persistence**: Keep form state in `localStorage`, IndexedDB, or custom storage with debounced writes
|
|
95
|
+
- **Validation batching**: Aggregate touched/errors updates to avoid jitter in large forms
|
|
96
|
+
- **Wizard & history helpers**: Higher-level APIs for multi-step flows and undo/redo stacks
|
|
97
|
+
- **Signal ↔ Observable bridge**: Convert signals to RxJS streams for interoperability
|
|
98
|
+
- **Template-driven adapter**: `SignalValueDirective` bridges standalone signals with `ngModel`
|
|
276
99
|
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
@Component({
|
|
282
|
-
template: `
|
|
283
|
-
<form [formGroup]="dynamicForm" (ngSubmit)="onSubmit()">
|
|
284
|
-
@for (field of formFields(); track field.name) {
|
|
285
|
-
<div class="field">
|
|
286
|
-
<label>{{ field.label }}</label>
|
|
287
|
-
|
|
288
|
-
@switch (field.type) { @case ('text') {
|
|
289
|
-
<input [formControlName]="field.name" type="text" />
|
|
290
|
-
} @case ('email') {
|
|
291
|
-
<input [formControlName]="field.name" type="email" />
|
|
292
|
-
} @case ('number') {
|
|
293
|
-
<input [formControlName]="field.name" type="number" />
|
|
294
|
-
} @case ('checkbox') {
|
|
295
|
-
<input [formControlName]="field.name" type="checkbox" />
|
|
296
|
-
} @case ('select') {
|
|
297
|
-
<select [formControlName]="field.name">
|
|
298
|
-
@for (option of field.options; track option.value) {
|
|
299
|
-
<option [value]="option.value">{{ option.label }}</option>
|
|
300
|
-
}
|
|
301
|
-
</select>
|
|
302
|
-
} }
|
|
303
|
-
|
|
304
|
-
<div class="validation-messages">
|
|
305
|
-
@if (dynamicForm.get(field.name)?.invalid && dynamicForm.get(field.name)?.touched) {
|
|
306
|
-
<span class="error">{{ field.label }} is invalid</span>
|
|
307
|
-
}
|
|
308
|
-
</div>
|
|
309
|
-
</div>
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
<button type="submit" [disabled]="dynamicForm.invalid">Submit</button>
|
|
313
|
-
</form>
|
|
314
|
-
`,
|
|
315
|
-
})
|
|
316
|
-
class DynamicFormComponent {
|
|
317
|
-
formFields = computed(() => tree.$.formConfig.fields());
|
|
318
|
-
dynamicForm = this.dynamicFormService.generateForm(this.formFields());
|
|
319
|
-
|
|
320
|
-
constructor(private dynamicFormService: DynamicFormService) {}
|
|
321
|
-
|
|
322
|
-
onSubmit() {
|
|
323
|
-
if (this.dynamicForm.valid) {
|
|
324
|
-
console.log('Dynamic Form Data:', tree.$.dynamicData());
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
## Advanced configuration
|
|
100
|
+
## Form tree configuration
|
|
331
101
|
|
|
332
102
|
```typescript
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
trackFormState: true, // Track dirty, touched, valid states
|
|
349
|
-
persistFormState: true, // Persist across navigation
|
|
350
|
-
|
|
351
|
-
// Custom error messages
|
|
352
|
-
errorMessages: {
|
|
353
|
-
required: 'This field is required',
|
|
354
|
-
email: 'Please enter a valid email',
|
|
355
|
-
minlength: 'Minimum length not met',
|
|
356
|
-
maxlength: 'Maximum length exceeded',
|
|
357
|
-
},
|
|
358
|
-
|
|
359
|
-
// Custom validators
|
|
360
|
-
validators: {
|
|
361
|
-
strongPassword: (control: AbstractControl) => {
|
|
362
|
-
const value = control.value;
|
|
363
|
-
const hasNumber = /[0-9]/.test(value);
|
|
364
|
-
const hasUpper = /[A-Z]/.test(value);
|
|
365
|
-
const hasLower = /[a-z]/.test(value);
|
|
366
|
-
const hasSpecial = /[#?!@$%^&*-]/.test(value);
|
|
367
|
-
|
|
368
|
-
const valid = hasNumber && hasUpper && hasLower && hasSpecial;
|
|
369
|
-
return valid ? null : { strongPassword: true };
|
|
370
|
-
},
|
|
103
|
+
const checkout = createFormTree(initialState, {
|
|
104
|
+
validators: {
|
|
105
|
+
'shipping.zip': (value) => (/^[0-9]{5}$/.test(String(value)) ? null : 'Enter a valid ZIP code'),
|
|
106
|
+
},
|
|
107
|
+
asyncValidators: {
|
|
108
|
+
'account.email': async (value) => ((await emailService.isTaken(value)) ? 'Email already used' : null),
|
|
109
|
+
},
|
|
110
|
+
fieldConfigs: {
|
|
111
|
+
'payment.card.number': { debounceMs: 200 },
|
|
112
|
+
'preferences.*': { validators: validators.required() },
|
|
113
|
+
},
|
|
114
|
+
conditionals: [
|
|
115
|
+
{
|
|
116
|
+
when: (values) => values.shipping.sameAsBilling,
|
|
117
|
+
fields: ['shipping.address', 'shipping.city', 'shipping.zip'],
|
|
371
118
|
},
|
|
372
|
-
|
|
373
|
-
|
|
119
|
+
],
|
|
120
|
+
persistKey: 'checkout-draft',
|
|
121
|
+
storage: sessionStorage,
|
|
122
|
+
persistDebounceMs: 500,
|
|
123
|
+
validationBatchMs: 16,
|
|
124
|
+
});
|
|
374
125
|
```
|
|
375
126
|
|
|
376
|
-
|
|
127
|
+
- `validators` / `asyncValidators`: Map paths (supports `*` globs) to declarative validation functions
|
|
128
|
+
- `fieldConfigs`: Attach validators and per-field debounce without scattering logic
|
|
129
|
+
- `conditionals`: Automatically disable controls when predicates fail
|
|
130
|
+
- `persistKey` + `storage`: Load persisted values on creation and auto-save thereafter
|
|
131
|
+
- `validationBatchMs`: Batch aggregate signal updates when running lots of validators at once
|
|
377
132
|
|
|
378
|
-
|
|
133
|
+
## Wizard flows
|
|
379
134
|
|
|
380
135
|
```typescript
|
|
381
|
-
|
|
382
|
-
currentStep: number;
|
|
383
|
-
steps: {
|
|
384
|
-
personal: {
|
|
385
|
-
firstName: string;
|
|
386
|
-
lastName: string;
|
|
387
|
-
email: string;
|
|
388
|
-
phone: string;
|
|
389
|
-
};
|
|
390
|
-
address: {
|
|
391
|
-
street: string;
|
|
392
|
-
city: string;
|
|
393
|
-
state: string;
|
|
394
|
-
zipCode: string;
|
|
395
|
-
};
|
|
396
|
-
preferences: {
|
|
397
|
-
newsletter: boolean;
|
|
398
|
-
notifications: boolean;
|
|
399
|
-
theme: 'light' | 'dark';
|
|
400
|
-
};
|
|
401
|
-
};
|
|
402
|
-
validation: {
|
|
403
|
-
personalValid: boolean;
|
|
404
|
-
addressValid: boolean;
|
|
405
|
-
preferencesValid: boolean;
|
|
406
|
-
};
|
|
407
|
-
}
|
|
136
|
+
import { createWizardForm, FormStep } from '@signaltree/ng-forms';
|
|
408
137
|
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
email: '',
|
|
416
|
-
phone: '',
|
|
417
|
-
},
|
|
418
|
-
address: {
|
|
419
|
-
street: '',
|
|
420
|
-
city: '',
|
|
421
|
-
state: '',
|
|
422
|
-
zipCode: '',
|
|
423
|
-
},
|
|
424
|
-
preferences: {
|
|
425
|
-
newsletter: false,
|
|
426
|
-
notifications: true,
|
|
427
|
-
theme: 'light',
|
|
138
|
+
const steps: FormStep<AccountSetup>[] = [
|
|
139
|
+
{
|
|
140
|
+
fields: ['profile.name', 'profile.email'],
|
|
141
|
+
validate: async (form) => {
|
|
142
|
+
await form.validate('profile.email');
|
|
143
|
+
return !form.getFieldError('profile.email')();
|
|
428
144
|
},
|
|
429
145
|
},
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
addressValid: false,
|
|
433
|
-
preferencesValid: false,
|
|
146
|
+
{
|
|
147
|
+
fields: ['security.password', 'security.confirm'],
|
|
434
148
|
},
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
@Component({
|
|
438
|
-
template: `
|
|
439
|
-
<div class="wizard">
|
|
440
|
-
<div class="wizard-steps">
|
|
441
|
-
@for (step of steps; track step.number) {
|
|
442
|
-
<div class="step" [class.active]="currentStep() === step.number" [class.completed]="isStepCompleted(step.number)">
|
|
443
|
-
{{ step.title }}
|
|
444
|
-
</div>
|
|
445
|
-
}
|
|
446
|
-
</div>
|
|
447
|
-
|
|
448
|
-
<div class="wizard-content">
|
|
449
|
-
@switch (currentStep()) { @case (1) {
|
|
450
|
-
<form [formGroup]="personalForm">
|
|
451
|
-
<h2>Personal Information</h2>
|
|
452
|
-
<input formControlName="firstName" placeholder="First Name" />
|
|
453
|
-
<input formControlName="lastName" placeholder="Last Name" />
|
|
454
|
-
<input formControlName="email" placeholder="Email" />
|
|
455
|
-
<input formControlName="phone" placeholder="Phone" />
|
|
456
|
-
</form>
|
|
457
|
-
} @case (2) {
|
|
458
|
-
<form [formGroup]="addressForm">
|
|
459
|
-
<h2>Address Information</h2>
|
|
460
|
-
<input formControlName="street" placeholder="Street Address" />
|
|
461
|
-
<input formControlName="city" placeholder="City" />
|
|
462
|
-
<input formControlName="state" placeholder="State" />
|
|
463
|
-
<input formControlName="zipCode" placeholder="ZIP Code" />
|
|
464
|
-
</form>
|
|
465
|
-
} @case (3) {
|
|
466
|
-
<form [formGroup]="preferencesForm">
|
|
467
|
-
<h2>Preferences</h2>
|
|
468
|
-
<label>
|
|
469
|
-
<input formControlName="newsletter" type="checkbox" />
|
|
470
|
-
Subscribe to newsletter
|
|
471
|
-
</label>
|
|
472
|
-
<label>
|
|
473
|
-
<input formControlName="notifications" type="checkbox" />
|
|
474
|
-
Enable notifications
|
|
475
|
-
</label>
|
|
476
|
-
<select formControlName="theme">
|
|
477
|
-
<option value="light">Light Theme</option>
|
|
478
|
-
<option value="dark">Dark Theme</option>
|
|
479
|
-
</select>
|
|
480
|
-
</form>
|
|
481
|
-
} }
|
|
482
|
-
</div>
|
|
483
|
-
|
|
484
|
-
<div class="wizard-navigation">
|
|
485
|
-
<button (click)="previousStep()" [disabled]="currentStep() === 1">Previous</button>
|
|
486
|
-
|
|
487
|
-
@if (currentStep() < 3) {
|
|
488
|
-
<button (click)="nextStep()" [disabled]="!canProceed()">Next</button>
|
|
489
|
-
} @else {
|
|
490
|
-
<button (click)="submitWizard()" [disabled]="!allStepsValid()">Submit</button>
|
|
491
|
-
}
|
|
492
|
-
</div>
|
|
493
|
-
</div>
|
|
494
|
-
`,
|
|
495
|
-
})
|
|
496
|
-
class WizardComponent {
|
|
497
|
-
wizardTree = wizardTree;
|
|
498
|
-
currentStep = computed(() => this.wizardTree.$.currentStep());
|
|
499
|
-
|
|
500
|
-
steps = [
|
|
501
|
-
{ number: 1, title: 'Personal' },
|
|
502
|
-
{ number: 2, title: 'Address' },
|
|
503
|
-
{ number: 3, title: 'Preferences' },
|
|
504
|
-
];
|
|
505
|
-
|
|
506
|
-
// Create forms for each step
|
|
507
|
-
personalForm = this.wizardTree.createForm('steps.personal', {
|
|
508
|
-
firstName: ['', Validators.required],
|
|
509
|
-
lastName: ['', Validators.required],
|
|
510
|
-
email: ['', [Validators.required, Validators.email]],
|
|
511
|
-
phone: ['', Validators.required],
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
addressForm = this.wizardTree.createForm('steps.address', {
|
|
515
|
-
street: ['', Validators.required],
|
|
516
|
-
city: ['', Validators.required],
|
|
517
|
-
state: ['', Validators.required],
|
|
518
|
-
zipCode: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
preferencesForm = this.wizardTree.createForm('steps.preferences');
|
|
522
|
-
|
|
523
|
-
// Track form validity
|
|
524
|
-
constructor() {
|
|
525
|
-
// Update validation state when forms change
|
|
526
|
-
effect(() => {
|
|
527
|
-
this.wizardTree.$.validation.personalValid.set(this.personalForm.valid);
|
|
528
|
-
this.wizardTree.$.validation.addressValid.set(this.addressForm.valid);
|
|
529
|
-
this.wizardTree.$.validation.preferencesValid.set(this.preferencesForm.valid);
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
nextStep() {
|
|
534
|
-
if (this.canProceed()) {
|
|
535
|
-
this.wizardTree.$.currentStep.update((step) => step + 1);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
previousStep() {
|
|
540
|
-
this.wizardTree.$.currentStep.update((step) => Math.max(1, step - 1));
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
canProceed(): boolean {
|
|
544
|
-
const step = this.currentStep();
|
|
545
|
-
const validation = this.wizardTree.$.validation();
|
|
546
|
-
|
|
547
|
-
switch (step) {
|
|
548
|
-
case 1:
|
|
549
|
-
return validation.personalValid;
|
|
550
|
-
case 2:
|
|
551
|
-
return validation.addressValid;
|
|
552
|
-
case 3:
|
|
553
|
-
return validation.preferencesValid;
|
|
554
|
-
default:
|
|
555
|
-
return false;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
isStepCompleted(stepNumber: number): boolean {
|
|
560
|
-
const validation = this.wizardTree.$.validation();
|
|
561
|
-
switch (stepNumber) {
|
|
562
|
-
case 1:
|
|
563
|
-
return validation.personalValid;
|
|
564
|
-
case 2:
|
|
565
|
-
return validation.addressValid;
|
|
566
|
-
case 3:
|
|
567
|
-
return validation.preferencesValid;
|
|
568
|
-
default:
|
|
569
|
-
return false;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
allStepsValid(): boolean {
|
|
574
|
-
const validation = this.wizardTree.$.validation();
|
|
575
|
-
return validation.personalValid && validation.addressValid && validation.preferencesValid;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
submitWizard() {
|
|
579
|
-
if (this.allStepsValid()) {
|
|
580
|
-
const wizardData = this.wizardTree.$.steps();
|
|
581
|
-
console.log('Wizard completed:', wizardData);
|
|
582
|
-
// Submit to API
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
### Dynamic Survey Builder
|
|
589
|
-
|
|
590
|
-
```typescript
|
|
591
|
-
interface Question {
|
|
592
|
-
id: string;
|
|
593
|
-
type: 'text' | 'number' | 'select' | 'radio' | 'checkbox';
|
|
594
|
-
label: string;
|
|
595
|
-
required: boolean;
|
|
596
|
-
options?: string[];
|
|
597
|
-
validation?: {
|
|
598
|
-
min?: number;
|
|
599
|
-
max?: number;
|
|
600
|
-
pattern?: string;
|
|
601
|
-
};
|
|
602
|
-
}
|
|
149
|
+
];
|
|
603
150
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
surveyId: string;
|
|
612
|
-
responses: Record<string, any>;
|
|
613
|
-
submittedAt?: Date;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const surveyTree = signalTree({
|
|
617
|
-
survey: null as Survey | null,
|
|
618
|
-
responses: {} as Record<string, any>,
|
|
619
|
-
currentQuestion: 0,
|
|
620
|
-
isSubmitting: false,
|
|
621
|
-
submitError: null as string | null,
|
|
151
|
+
const wizard = createWizardForm(steps, initialValues, {
|
|
152
|
+
conditionals: [
|
|
153
|
+
{
|
|
154
|
+
when: ({ marketingOptIn }) => marketingOptIn,
|
|
155
|
+
fields: ['preferences.frequency'],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
622
158
|
});
|
|
623
159
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
<div class="progress">
|
|
630
|
-
<div class="progress-bar" [style.width.%]="progressPercent()"></div>
|
|
631
|
-
</div>
|
|
632
|
-
|
|
633
|
-
<form [formGroup]="surveyForm" (ngSubmit)="submitSurvey()">
|
|
634
|
-
@for (question of survey()!.questions; track question.id; let i = $index) {
|
|
635
|
-
<div class="question" [class.active]="currentQuestion() === i">
|
|
636
|
-
<h3>{{ question.label }}</h3>
|
|
637
|
-
|
|
638
|
-
@switch (question.type) { @case ('text') {
|
|
639
|
-
<input [formControlName]="question.id" type="text" [placeholder]="question.label" />
|
|
640
|
-
} @case ('number') {
|
|
641
|
-
<input [formControlName]="question.id" type="number" [min]="question.validation?.min" [max]="question.validation?.max" />
|
|
642
|
-
} @case ('select') {
|
|
643
|
-
<select [formControlName]="question.id">
|
|
644
|
-
<option value="">Select an option</option>
|
|
645
|
-
@for (option of question.options; track option) {
|
|
646
|
-
<option [value]="option">{{ option }}</option>
|
|
647
|
-
}
|
|
648
|
-
</select>
|
|
649
|
-
} @case ('radio') { @for (option of question.options; track option) {
|
|
650
|
-
<label>
|
|
651
|
-
<input [formControlName]="question.id" type="radio" [value]="option" />
|
|
652
|
-
{{ option }}
|
|
653
|
-
</label>
|
|
654
|
-
} } @case ('checkbox') { @for (option of question.options; track option) {
|
|
655
|
-
<label>
|
|
656
|
-
<input type="checkbox" [value]="option" (change)="onCheckboxChange(question.id, option, $event)" />
|
|
657
|
-
{{ option }}
|
|
658
|
-
</label>
|
|
659
|
-
} } }
|
|
660
|
-
|
|
661
|
-
<div class="validation-error" *ngIf="getQuestionError(question.id)">
|
|
662
|
-
{{ getQuestionError(question.id) }}
|
|
663
|
-
</div>
|
|
664
|
-
</div>
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
<div class="survey-navigation">
|
|
668
|
-
<button type="button" (click)="previousQuestion()" [disabled]="currentQuestion() === 0">Previous</button>
|
|
669
|
-
|
|
670
|
-
@if (currentQuestion() < survey()!.questions.length - 1) {
|
|
671
|
-
<button type="button" (click)="nextQuestion()" [disabled]="!isCurrentQuestionValid()">Next</button>
|
|
672
|
-
} @else {
|
|
673
|
-
<button type="submit" [disabled]="surveyForm.invalid || isSubmitting()">
|
|
674
|
-
{{ isSubmitting() ? 'Submitting...' : 'Submit Survey' }}
|
|
675
|
-
</button>
|
|
676
|
-
}
|
|
677
|
-
</div>
|
|
678
|
-
</form>
|
|
679
|
-
|
|
680
|
-
<div class="error" *ngIf="submitError()">
|
|
681
|
-
{{ submitError() }}
|
|
682
|
-
</div>
|
|
683
|
-
</div>
|
|
684
|
-
`,
|
|
685
|
-
})
|
|
686
|
-
class SurveyComponent implements OnInit {
|
|
687
|
-
surveyTree = surveyTree;
|
|
688
|
-
surveyForm!: FormGroup;
|
|
689
|
-
|
|
690
|
-
survey = computed(() => this.surveyTree.$.survey());
|
|
691
|
-
currentQuestion = computed(() => this.surveyTree.$.currentQuestion());
|
|
692
|
-
isSubmitting = computed(() => this.surveyTree.$.isSubmitting());
|
|
693
|
-
submitError = computed(() => this.surveyTree.$.submitError());
|
|
694
|
-
|
|
695
|
-
progressPercent = computed(() => {
|
|
696
|
-
const survey = this.survey();
|
|
697
|
-
const current = this.currentQuestion();
|
|
698
|
-
if (!survey) return 0;
|
|
699
|
-
return ((current + 1) / survey.questions.length) * 100;
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
ngOnInit() {
|
|
703
|
-
// Load survey and create form
|
|
704
|
-
this.loadSurvey().then((survey) => {
|
|
705
|
-
this.surveyTree.$.survey.set(survey);
|
|
706
|
-
this.createSurveyForm(survey);
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
private createSurveyForm(survey: Survey) {
|
|
711
|
-
const formConfig: Record<string, any> = {};
|
|
712
|
-
|
|
713
|
-
survey.questions.forEach((question) => {
|
|
714
|
-
const validators = [];
|
|
715
|
-
|
|
716
|
-
if (question.required) {
|
|
717
|
-
validators.push(Validators.required);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
if (question.validation) {
|
|
721
|
-
if (question.validation.min !== undefined) {
|
|
722
|
-
validators.push(Validators.min(question.validation.min));
|
|
723
|
-
}
|
|
724
|
-
if (question.validation.max !== undefined) {
|
|
725
|
-
validators.push(Validators.max(question.validation.max));
|
|
726
|
-
}
|
|
727
|
-
if (question.validation.pattern) {
|
|
728
|
-
validators.push(Validators.pattern(question.validation.pattern));
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
const defaultValue = question.type === 'checkbox' ? [] : '';
|
|
733
|
-
formConfig[question.id] = [defaultValue, validators];
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
this.surveyForm = this.surveyTree.createForm('responses', formConfig);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
nextQuestion() {
|
|
740
|
-
if (this.isCurrentQuestionValid()) {
|
|
741
|
-
this.surveyTree.$.currentQuestion.update((q) => q + 1);
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
previousQuestion() {
|
|
746
|
-
this.surveyTree.$.currentQuestion.update((q) => Math.max(0, q - 1));
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
isCurrentQuestionValid(): boolean {
|
|
750
|
-
const survey = this.survey();
|
|
751
|
-
const current = this.currentQuestion();
|
|
752
|
-
if (!survey) return false;
|
|
753
|
-
|
|
754
|
-
const question = survey.questions[current];
|
|
755
|
-
const control = this.surveyForm.get(question.id);
|
|
756
|
-
return control ? control.valid : false;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
getQuestionError(questionId: string): string | null {
|
|
760
|
-
const control = this.surveyForm.get(questionId);
|
|
761
|
-
if (control?.errors && control.touched) {
|
|
762
|
-
if (control.errors['required']) return 'This question is required';
|
|
763
|
-
if (control.errors['min']) return `Minimum value is ${control.errors['min'].min}`;
|
|
764
|
-
if (control.errors['max']) return `Maximum value is ${control.errors['max'].max}`;
|
|
765
|
-
if (control.errors['pattern']) return 'Invalid format';
|
|
766
|
-
}
|
|
767
|
-
return null;
|
|
768
|
-
}
|
|
160
|
+
await wizard.nextStep();
|
|
161
|
+
wizard.previousStep();
|
|
162
|
+
wizard.currentStep(); // readonly signal
|
|
163
|
+
wizard.isFieldVisible('preferences.frequency')();
|
|
164
|
+
```
|
|
769
165
|
|
|
770
|
-
|
|
771
|
-
const control = this.surveyForm.get(questionId);
|
|
772
|
-
const currentValue = control?.value || [];
|
|
166
|
+
Wizard forms reuse the same `form` instance and `FormTree` helpers, adding `currentStep`, `nextStep`, `previousStep`, `goToStep`, and `isFieldVisible` helpers for UI state.
|
|
773
167
|
|
|
774
|
-
|
|
775
|
-
control?.setValue([...currentValue, option]);
|
|
776
|
-
} else {
|
|
777
|
-
control?.setValue(currentValue.filter((v: string) => v !== option));
|
|
778
|
-
}
|
|
779
|
-
}
|
|
168
|
+
## Form history snapshots
|
|
780
169
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
this.surveyTree.$.isSubmitting.set(true);
|
|
784
|
-
this.surveyTree.$.submitError.set(null);
|
|
785
|
-
|
|
786
|
-
try {
|
|
787
|
-
const response: SurveyResponse = {
|
|
788
|
-
surveyId: this.survey()!.id,
|
|
789
|
-
responses: this.surveyTree.$.responses(),
|
|
790
|
-
submittedAt: new Date(),
|
|
791
|
-
};
|
|
792
|
-
|
|
793
|
-
await this.submitSurveyResponse(response);
|
|
794
|
-
console.log('Survey submitted successfully');
|
|
795
|
-
} catch (error) {
|
|
796
|
-
this.surveyTree.$.submitError.set('Failed to submit survey. Please try again.');
|
|
797
|
-
} finally {
|
|
798
|
-
this.surveyTree.$.isSubmitting.set(false);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
}
|
|
170
|
+
```typescript
|
|
171
|
+
import { withFormHistory } from '@signaltree/ng-forms';
|
|
802
172
|
|
|
803
|
-
|
|
804
|
-
// Load survey from API
|
|
805
|
-
return {
|
|
806
|
-
id: '1',
|
|
807
|
-
title: 'Customer Satisfaction Survey',
|
|
808
|
-
questions: [
|
|
809
|
-
{
|
|
810
|
-
id: 'q1',
|
|
811
|
-
type: 'radio',
|
|
812
|
-
label: 'How satisfied are you with our service?',
|
|
813
|
-
required: true,
|
|
814
|
-
options: ['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied', 'Very Dissatisfied'],
|
|
815
|
-
},
|
|
816
|
-
{
|
|
817
|
-
id: 'q2',
|
|
818
|
-
type: 'text',
|
|
819
|
-
label: 'What can we improve?',
|
|
820
|
-
required: false,
|
|
821
|
-
},
|
|
822
|
-
{
|
|
823
|
-
id: 'q3',
|
|
824
|
-
type: 'number',
|
|
825
|
-
label: 'Rate us from 1-10',
|
|
826
|
-
required: true,
|
|
827
|
-
validation: { min: 1, max: 10 },
|
|
828
|
-
},
|
|
829
|
-
],
|
|
830
|
-
};
|
|
831
|
-
}
|
|
173
|
+
const form = withFormHistory(createFormTree(initialValues), { capacity: 20 });
|
|
832
174
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
175
|
+
form.setValue('profile.name', 'Ada');
|
|
176
|
+
form.undo();
|
|
177
|
+
form.redo();
|
|
178
|
+
form.history(); // signal with { past, present, future }
|
|
179
|
+
form.clearHistory();
|
|
838
180
|
```
|
|
839
181
|
|
|
840
|
-
|
|
182
|
+
History tracking works at the FormGroup level so it plays nicely with external updates and preserved snapshots.
|
|
841
183
|
|
|
842
|
-
|
|
184
|
+
## Helpers and utilities
|
|
843
185
|
|
|
844
|
-
-
|
|
845
|
-
-
|
|
846
|
-
-
|
|
847
|
-
-
|
|
848
|
-
-
|
|
849
|
-
- Applications requiring form state persistence
|
|
186
|
+
- `validators` / `asyncValidators`: Lightweight factories for common rules (required, email, minLength, unique, etc.)
|
|
187
|
+
- `createVirtualFormArray`: Virtualize huge `FormArray`s by only instantiating the visible window
|
|
188
|
+
- `toObservable(signal)`: Convert any Angular signal to an RxJS `Observable`
|
|
189
|
+
- `SIGNAL_FORM_DIRECTIVES`: Re-export of `SignalValueDirective` for template-driven helpers
|
|
190
|
+
- `FormValidationError`: Error thrown from `submit` when validation fails, containing sync & async errors
|
|
850
191
|
|
|
851
|
-
##
|
|
192
|
+
## Template-driven bridge
|
|
852
193
|
|
|
853
|
-
```
|
|
854
|
-
|
|
855
|
-
import { withDevTools } from '@signaltree/devtools';
|
|
856
|
-
|
|
857
|
-
// Compose DevTools with your app tree separately if desired
|
|
858
|
-
const form = createFormTree(state);
|
|
859
|
-
// In development, you can enhance your app's SignalTree with DevTools
|
|
860
|
-
// const appTree = signalTree(appState).with(withDevTools());
|
|
194
|
+
```html
|
|
195
|
+
<input type="text" [(ngModel)]="userName" [signalTreeSignalValue]="formTree.$.user.name" (signalTreeSignalValueChange)="audit($event)" />
|
|
861
196
|
```
|
|
862
197
|
|
|
863
|
-
|
|
198
|
+
Use `SignalValueDirective` to keep standalone signals and `ngModel` fields aligned in legacy sections while new pages migrate to forms-first APIs.
|
|
199
|
+
|
|
200
|
+
## When to reach for ng-forms
|
|
864
201
|
|
|
865
|
-
-
|
|
866
|
-
-
|
|
867
|
-
-
|
|
868
|
-
-
|
|
869
|
-
- **Memory efficient** form state management
|
|
202
|
+
- Complex Angular forms that need to remain in sync with SignalTree application state
|
|
203
|
+
- Workflows that require persistence, auto-save, or offline drafts
|
|
204
|
+
- Multi-step wizards or surveys with dynamic branching
|
|
205
|
+
- Applications that benefit from first-class signal APIs around Angular forms
|
|
870
206
|
|
|
871
207
|
## Links
|
|
872
208
|
|
|
873
209
|
- [SignalTree Documentation](https://signaltree.io)
|
|
874
210
|
- [Core Package](https://www.npmjs.com/package/@signaltree/core)
|
|
875
211
|
- [GitHub Repository](https://github.com/JBorgia/signaltree)
|
|
876
|
-
- [
|
|
212
|
+
- [Demo Application](https://signaltree.io/examples)
|
|
877
213
|
|
|
878
214
|
## License
|
|
879
215
|
|
|
880
|
-
MIT License with AI Training Restriction
|
|
216
|
+
MIT License with AI Training Restriction — see the [LICENSE](../../LICENSE) file for details.
|
|
881
217
|
|
|
882
218
|
---
|
|
883
219
|
|
|
884
|
-
**Seamless Angular
|
|
220
|
+
**Seamless signal-first Angular forms.**
|