@signaltree/ng-forms 3.0.2 → 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 CHANGED
@@ -1,884 +1,220 @@
1
1
  # @signaltree/ng-forms
2
2
 
3
- Angular Forms integration for SignalTree featuring reactive forms binding, validation, form state management, and seamless Angular integration.
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
- npm install @signaltree/core @signaltree/ng-forms
10
+ pnpm add @signaltree/core @signaltree/ng-forms
22
11
  ```
23
12
 
24
- ## Basic usage
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
- ## Core features
50
-
51
- ### Reactive Forms Integration
15
+ ## Quick start
52
16
 
53
17
  ```typescript
54
18
  import { Component } from '@angular/core';
55
- import { FormBuilder, Validators } from '@angular/forms';
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]="userForm" (ngSubmit)="onSubmit()">
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
- <input formControlName="age" type="number" placeholder="Age" />
37
+ <span class="error" *ngIf="profile.getFieldError('email')()">
38
+ {{ profile.getFieldError('email')() }}
39
+ </span>
64
40
 
65
- <button type="submit" [disabled]="userForm.invalid">Submit</button>
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
- <!-- Real-time state display -->
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 UserFormComponent {
77
- tree = signalTree({
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
- ### Template-Driven Forms
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
- onSubmit(form: NgForm) {
128
- if (form.valid) {
129
- console.log('User Data:', this.tree.$.user());
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
- const passwordMatchValidator = (group: AbstractControl) => {
161
- const password = group.get('password')?.value;
162
- const confirmPassword = group.get('confirmPassword')?.value;
163
- return password === confirmPassword ? null : { passwordMismatch: true };
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
- ### Dynamic Form Generation
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
- @Injectable()
265
- class DynamicFormService {
266
- generateForm(fields: FormField[]) {
267
- const formConfig: Record<string, any> = {};
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
- fields.forEach((field) => {
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
- formConfig[field.name] = ['', validators];
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
- return tree.createForm('dynamicData', formConfig);
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 tree = signalTree(state).with(
334
- /* createFormTree options can be passed here if applicable */
335
- // Automatic synchronization settings
336
- autoSync: true,
337
- syncDirection: 'bidirectional', // 'toForm' | 'toState' | 'bidirectional'
338
-
339
- // Debounce settings for performance
340
- debounceTime: 300,
341
-
342
- // Validation settings
343
- validateOnChange: true,
344
- validateOnBlur: true,
345
- showErrorsOnTouched: true,
346
-
347
- // Form state management
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
- ## Real-world examples
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
- ### Multi-Step Form Wizard
133
+ ## Wizard flows
379
134
 
380
135
  ```typescript
381
- interface WizardState {
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 wizardTree = signalTree<WizardState>({
410
- currentStep: 1,
411
- steps: {
412
- personal: {
413
- firstName: '',
414
- lastName: '',
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
- validation: {
431
- personalValid: false,
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
- interface Survey {
605
- id: string;
606
- title: string;
607
- questions: Question[];
608
- }
609
-
610
- interface SurveyResponse {
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
- @Component({
625
- template: `
626
- <div class="survey" *ngIf="survey()">
627
- <h1>{{ survey()!.title }}</h1>
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
- onCheckboxChange(questionId: string, option: string, event: any) {
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
- if (event.target.checked) {
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
- async submitSurvey() {
782
- if (this.surveyForm.valid) {
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
- private async loadSurvey(): Promise<Survey> {
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
- private async submitSurveyResponse(response: SurveyResponse): Promise<void> {
834
- // Submit to API
835
- await new Promise((resolve) => setTimeout(resolve, 1000));
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
- ## When to use ng-forms
182
+ History tracking works at the FormGroup level so it plays nicely with external updates and preserved snapshots.
841
183
 
842
- Perfect for:
184
+ ## Helpers and utilities
843
185
 
844
- - Angular applications with complex forms
845
- - Real-time form validation and feedback
846
- - Multi-step forms and wizards
847
- - Dynamic form generation
848
- - Forms with cross-field validation
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
- ## Composition with other packages
192
+ ## Template-driven bridge
852
193
 
853
- ```typescript
854
- import { createFormTree } from '@signaltree/ng-forms';
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
- ## Performance benefits
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
- - **Automatic synchronization** between forms and state
866
- - **Debounced updates** prevent excessive state changes
867
- - **Efficient validation** with minimal re-computation
868
- - **Tree-shakeable** - only includes what you use
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
- - [ng-forms Examples](https://signaltree.io/examples/ng-forms)
212
+ - [Demo Application](https://signaltree.io/examples)
877
213
 
878
214
  ## License
879
215
 
880
- MIT License with AI Training Restriction - see the [LICENSE](../../LICENSE) file for details.
216
+ MIT License with AI Training Restriction see the [LICENSE](../../LICENSE) file for details.
881
217
 
882
218
  ---
883
219
 
884
- **Seamless Angular Forms** with SignalTree power! 🅰️
220
+ **Seamless signal-first Angular forms.**