@signaltree/ng-forms 1.0.0 → 1.0.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.
Files changed (2) hide show
  1. package/README.md +882 -4
  2. package/package.json +2 -1
package/README.md CHANGED
@@ -1,7 +1,885 @@
1
- # ng-forms
1
+ # @signaltree/ng-forms
2
2
 
3
- This library was generated with [Nx](https://nx.dev).
3
+ Angular Forms integration for SignalTree featuring reactive forms binding, validation, form state management, and seamless Angular integration.
4
4
 
5
- ## Running unit tests
5
+ ## What is @signaltree/ng-forms?
6
6
 
7
- Run `nx test ng-forms` to execute the unit tests.
7
+ The ng-forms package provides deep Angular Forms integration:
8
+
9
+ - **Reactive Forms binding** with automatic synchronization
10
+ - **Template-driven forms** support with signals
11
+ - **Advanced validation** with real-time feedback
12
+ - **Form state management** (dirty, touched, valid states)
13
+ - **Dynamic form generation** from SignalTree state
14
+ - **Cross-field validation** and complex form logic
15
+
16
+ ## 🚀 Installation
17
+
18
+ ```bash
19
+ npm install @signaltree/core @signaltree/ng-forms
20
+ ```
21
+
22
+ ## 📖 Basic Usage
23
+
24
+ ```typescript
25
+ import { signalTree } from '@signaltree/core';
26
+ import { withForms } from '@signaltree/ng-forms';
27
+
28
+ const tree = signalTree({
29
+ user: {
30
+ name: '',
31
+ email: '',
32
+ age: 0,
33
+ },
34
+ preferences: {
35
+ newsletter: false,
36
+ theme: 'light',
37
+ },
38
+ }).pipe(withForms());
39
+
40
+ // Automatic form generation
41
+ const userForm = tree.createForm('user');
42
+ const preferencesForm = tree.createForm('preferences');
43
+
44
+ // Forms automatically sync with SignalTree state
45
+ ```
46
+
47
+ ## 🎯 Core Features
48
+
49
+ ### Reactive Forms Integration
50
+
51
+ ```typescript
52
+ import { Component } from '@angular/core';
53
+ import { FormBuilder, Validators } from '@angular/forms';
54
+
55
+ @Component({
56
+ template: `
57
+ <form [formGroup]="userForm" (ngSubmit)="onSubmit()">
58
+ <!-- Form automatically synced with SignalTree -->
59
+ <input formControlName="name" placeholder="Name" />
60
+ <input formControlName="email" placeholder="Email" />
61
+ <input formControlName="age" type="number" placeholder="Age" />
62
+
63
+ <button type="submit" [disabled]="userForm.invalid">Submit</button>
64
+ </form>
65
+
66
+ <!-- Real-time state display -->
67
+ <div>
68
+ <p>Current State: {{ tree.$.user() | json }}</p>
69
+ <p>Form Valid: {{ userForm.valid }}</p>
70
+ <p>Form Dirty: {{ userForm.dirty }}</p>
71
+ </div>
72
+ `,
73
+ })
74
+ class UserFormComponent {
75
+ tree = signalTree({
76
+ user: {
77
+ name: '',
78
+ email: '',
79
+ age: 0,
80
+ },
81
+ }).pipe(withForms());
82
+
83
+ // Create form with automatic binding
84
+ userForm = this.tree.createForm('user', {
85
+ name: ['', [Validators.required, Validators.minLength(2)]],
86
+ email: ['', [Validators.required, Validators.email]],
87
+ age: [0, [Validators.required, Validators.min(18)]],
88
+ });
89
+
90
+ onSubmit() {
91
+ if (this.userForm.valid) {
92
+ console.log('Form Data:', this.tree.$.user());
93
+ console.log('Form Value:', this.userForm.value);
94
+ // Both are automatically synchronized!
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ ### Template-Driven Forms
101
+
102
+ ```typescript
103
+ @Component({
104
+ template: `
105
+ <form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
106
+ <!-- Bind directly to SignalTree signals -->
107
+ <input name="name" ngModel [ngModel]="tree.$.user.name()" (ngModelChange)="tree.$.user.name.set($event)" #name="ngModel" required minlength="2" />
108
+ <div *ngIf="name.invalid && name.touched">Name is required (min 2 characters)</div>
109
+
110
+ <input name="email" type="email" ngModel [ngModel]="tree.$.user.email()" (ngModelChange)="tree.$.user.email.set($event)" #email="ngModel" required email />
111
+ <div *ngIf="email.invalid && email.touched">Valid email is required</div>
112
+
113
+ <button type="submit" [disabled]="userForm.invalid">Submit</button>
114
+ </form>
115
+ `,
116
+ })
117
+ class TemplateFormComponent {
118
+ tree = signalTree({
119
+ user: {
120
+ name: '',
121
+ email: '',
122
+ },
123
+ }).pipe(withForms());
124
+
125
+ onSubmit(form: NgForm) {
126
+ if (form.valid) {
127
+ console.log('User Data:', this.tree.$.user());
128
+ }
129
+ }
130
+ }
131
+ ```
132
+
133
+ ### Advanced Validation
134
+
135
+ ```typescript
136
+ const tree = signalTree({
137
+ registration: {
138
+ username: '',
139
+ email: '',
140
+ password: '',
141
+ confirmPassword: '',
142
+ agreeToTerms: false,
143
+ },
144
+ }).pipe(withForms());
145
+
146
+ // Custom validators that can access SignalTree state
147
+ const usernameAsyncValidator = (control: AbstractControl) => {
148
+ return of(control.value).pipe(
149
+ delay(300), // Debounce
150
+ switchMap((username) =>
151
+ // Check if username exists (can access tree state)
152
+ this.userService.checkUsernameAvailability(username)
153
+ ),
154
+ map((isAvailable) => (isAvailable ? null : { usernameTaken: true }))
155
+ );
156
+ };
157
+
158
+ const passwordMatchValidator = (group: AbstractControl) => {
159
+ const password = group.get('password')?.value;
160
+ const confirmPassword = group.get('confirmPassword')?.value;
161
+ return password === confirmPassword ? null : { passwordMismatch: true };
162
+ };
163
+
164
+ const registrationForm = tree.createForm(
165
+ 'registration',
166
+ {
167
+ username: [
168
+ '',
169
+ {
170
+ validators: [Validators.required, Validators.minLength(3)],
171
+ asyncValidators: [usernameAsyncValidator],
172
+ },
173
+ ],
174
+ email: ['', [Validators.required, Validators.email]],
175
+ password: ['', [Validators.required, Validators.minLength(8)]],
176
+ confirmPassword: ['', Validators.required],
177
+ agreeToTerms: [false, Validators.requiredTrue],
178
+ },
179
+ {
180
+ validators: [passwordMatchValidator], // Form-level validator
181
+ }
182
+ );
183
+
184
+ // Real-time validation feedback
185
+ @Component({
186
+ template: `
187
+ <form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">
188
+ <div class="field">
189
+ <input formControlName="username" placeholder="Username" />
190
+ <div class="validation-messages">
191
+ @if (registrationForm.get('username')?.hasError('required')) {
192
+ <span class="error">Username is required</span>
193
+ } @if (registrationForm.get('username')?.hasError('minlength')) {
194
+ <span class="error">Username must be at least 3 characters</span>
195
+ } @if (registrationForm.get('username')?.hasError('usernameTaken')) {
196
+ <span class="error">Username is already taken</span>
197
+ } @if (registrationForm.get('username')?.pending) {
198
+ <span class="checking">Checking availability...</span>
199
+ }
200
+ </div>
201
+ </div>
202
+
203
+ <div class="field">
204
+ <input formControlName="password" type="password" placeholder="Password" />
205
+ <div class="validation-messages">
206
+ @if (registrationForm.get('password')?.hasError('required')) {
207
+ <span class="error">Password is required</span>
208
+ } @if (registrationForm.get('password')?.hasError('minlength')) {
209
+ <span class="error">Password must be at least 8 characters</span>
210
+ }
211
+ </div>
212
+ </div>
213
+
214
+ <div class="field">
215
+ <input formControlName="confirmPassword" type="password" placeholder="Confirm Password" />
216
+ <div class="validation-messages">
217
+ @if (registrationForm.hasError('passwordMismatch')) {
218
+ <span class="error">Passwords don't match</span>
219
+ }
220
+ </div>
221
+ </div>
222
+
223
+ <label>
224
+ <input formControlName="agreeToTerms" type="checkbox" />
225
+ I agree to the terms and conditions
226
+ </label>
227
+
228
+ <button type="submit" [disabled]="registrationForm.invalid">Register</button>
229
+ </form>
230
+ `,
231
+ })
232
+ class RegistrationComponent {
233
+ registrationForm = registrationForm;
234
+
235
+ onSubmit() {
236
+ if (this.registrationForm.valid) {
237
+ console.log('Registration Data:', tree.$.registration());
238
+ }
239
+ }
240
+ }
241
+ ```
242
+
243
+ ### Dynamic Form Generation
244
+
245
+ ```typescript
246
+ interface FormField {
247
+ name: string;
248
+ type: 'text' | 'email' | 'number' | 'checkbox' | 'select';
249
+ label: string;
250
+ required?: boolean;
251
+ options?: Array<{ value: any; label: string }>;
252
+ validators?: any[];
253
+ }
254
+
255
+ const tree = signalTree({
256
+ dynamicData: {} as Record<string, any>,
257
+ formConfig: {
258
+ fields: [] as FormField[],
259
+ },
260
+ }).pipe(withForms());
261
+
262
+ @Injectable()
263
+ class DynamicFormService {
264
+ generateForm(fields: FormField[]) {
265
+ const formConfig: Record<string, any> = {};
266
+
267
+ fields.forEach((field) => {
268
+ const validators = [];
269
+ if (field.required) validators.push(Validators.required);
270
+ if (field.type === 'email') validators.push(Validators.email);
271
+
272
+ formConfig[field.name] = ['', validators];
273
+ });
274
+
275
+ return tree.createForm('dynamicData', formConfig);
276
+ }
277
+ }
278
+
279
+ @Component({
280
+ template: `
281
+ <form [formGroup]="dynamicForm" (ngSubmit)="onSubmit()">
282
+ @for (field of formFields(); track field.name) {
283
+ <div class="field">
284
+ <label>{{ field.label }}</label>
285
+
286
+ @switch (field.type) { @case ('text') {
287
+ <input [formControlName]="field.name" type="text" />
288
+ } @case ('email') {
289
+ <input [formControlName]="field.name" type="email" />
290
+ } @case ('number') {
291
+ <input [formControlName]="field.name" type="number" />
292
+ } @case ('checkbox') {
293
+ <input [formControlName]="field.name" type="checkbox" />
294
+ } @case ('select') {
295
+ <select [formControlName]="field.name">
296
+ @for (option of field.options; track option.value) {
297
+ <option [value]="option.value">{{ option.label }}</option>
298
+ }
299
+ </select>
300
+ } }
301
+
302
+ <div class="validation-messages">
303
+ @if (dynamicForm.get(field.name)?.invalid && dynamicForm.get(field.name)?.touched) {
304
+ <span class="error">{{ field.label }} is invalid</span>
305
+ }
306
+ </div>
307
+ </div>
308
+ }
309
+
310
+ <button type="submit" [disabled]="dynamicForm.invalid">Submit</button>
311
+ </form>
312
+ `,
313
+ })
314
+ class DynamicFormComponent {
315
+ formFields = computed(() => tree.$.formConfig.fields());
316
+ dynamicForm = this.dynamicFormService.generateForm(this.formFields());
317
+
318
+ constructor(private dynamicFormService: DynamicFormService) {}
319
+
320
+ onSubmit() {
321
+ if (this.dynamicForm.valid) {
322
+ console.log('Dynamic Form Data:', tree.$.dynamicData());
323
+ }
324
+ }
325
+ }
326
+ ```
327
+
328
+ ## 🔧 Advanced Configuration
329
+
330
+ ```typescript
331
+ const tree = signalTree(state).pipe(
332
+ withForms({
333
+ // Automatic synchronization settings
334
+ autoSync: true,
335
+ syncDirection: 'bidirectional', // 'toForm' | 'toState' | 'bidirectional'
336
+
337
+ // Debounce settings for performance
338
+ debounceTime: 300,
339
+
340
+ // Validation settings
341
+ validateOnChange: true,
342
+ validateOnBlur: true,
343
+ showErrorsOnTouched: true,
344
+
345
+ // Form state management
346
+ trackFormState: true, // Track dirty, touched, valid states
347
+ persistFormState: true, // Persist across navigation
348
+
349
+ // Custom error messages
350
+ errorMessages: {
351
+ required: 'This field is required',
352
+ email: 'Please enter a valid email',
353
+ minlength: 'Minimum length not met',
354
+ maxlength: 'Maximum length exceeded',
355
+ },
356
+
357
+ // Custom validators
358
+ validators: {
359
+ strongPassword: (control: AbstractControl) => {
360
+ const value = control.value;
361
+ const hasNumber = /[0-9]/.test(value);
362
+ const hasUpper = /[A-Z]/.test(value);
363
+ const hasLower = /[a-z]/.test(value);
364
+ const hasSpecial = /[#?!@$%^&*-]/.test(value);
365
+
366
+ const valid = hasNumber && hasUpper && hasLower && hasSpecial;
367
+ return valid ? null : { strongPassword: true };
368
+ },
369
+ },
370
+ })
371
+ );
372
+ ```
373
+
374
+ ## 📊 Real-World Examples
375
+
376
+ ### Multi-Step Form Wizard
377
+
378
+ ```typescript
379
+ interface WizardState {
380
+ currentStep: number;
381
+ steps: {
382
+ personal: {
383
+ firstName: string;
384
+ lastName: string;
385
+ email: string;
386
+ phone: string;
387
+ };
388
+ address: {
389
+ street: string;
390
+ city: string;
391
+ state: string;
392
+ zipCode: string;
393
+ };
394
+ preferences: {
395
+ newsletter: boolean;
396
+ notifications: boolean;
397
+ theme: 'light' | 'dark';
398
+ };
399
+ };
400
+ validation: {
401
+ personalValid: boolean;
402
+ addressValid: boolean;
403
+ preferencesValid: boolean;
404
+ };
405
+ }
406
+
407
+ const wizardTree = signalTree<WizardState>({
408
+ currentStep: 1,
409
+ steps: {
410
+ personal: {
411
+ firstName: '',
412
+ lastName: '',
413
+ email: '',
414
+ phone: '',
415
+ },
416
+ address: {
417
+ street: '',
418
+ city: '',
419
+ state: '',
420
+ zipCode: '',
421
+ },
422
+ preferences: {
423
+ newsletter: false,
424
+ notifications: true,
425
+ theme: 'light',
426
+ },
427
+ },
428
+ validation: {
429
+ personalValid: false,
430
+ addressValid: false,
431
+ preferencesValid: false,
432
+ },
433
+ }).pipe(withForms());
434
+
435
+ @Component({
436
+ template: `
437
+ <div class="wizard">
438
+ <div class="wizard-steps">
439
+ @for (step of steps; track step.number) {
440
+ <div class="step" [class.active]="currentStep() === step.number" [class.completed]="isStepCompleted(step.number)">
441
+ {{ step.title }}
442
+ </div>
443
+ }
444
+ </div>
445
+
446
+ <div class="wizard-content">
447
+ @switch (currentStep()) { @case (1) {
448
+ <form [formGroup]="personalForm">
449
+ <h2>Personal Information</h2>
450
+ <input formControlName="firstName" placeholder="First Name" />
451
+ <input formControlName="lastName" placeholder="Last Name" />
452
+ <input formControlName="email" placeholder="Email" />
453
+ <input formControlName="phone" placeholder="Phone" />
454
+ </form>
455
+ } @case (2) {
456
+ <form [formGroup]="addressForm">
457
+ <h2>Address Information</h2>
458
+ <input formControlName="street" placeholder="Street Address" />
459
+ <input formControlName="city" placeholder="City" />
460
+ <input formControlName="state" placeholder="State" />
461
+ <input formControlName="zipCode" placeholder="ZIP Code" />
462
+ </form>
463
+ } @case (3) {
464
+ <form [formGroup]="preferencesForm">
465
+ <h2>Preferences</h2>
466
+ <label>
467
+ <input formControlName="newsletter" type="checkbox" />
468
+ Subscribe to newsletter
469
+ </label>
470
+ <label>
471
+ <input formControlName="notifications" type="checkbox" />
472
+ Enable notifications
473
+ </label>
474
+ <select formControlName="theme">
475
+ <option value="light">Light Theme</option>
476
+ <option value="dark">Dark Theme</option>
477
+ </select>
478
+ </form>
479
+ } }
480
+ </div>
481
+
482
+ <div class="wizard-navigation">
483
+ <button (click)="previousStep()" [disabled]="currentStep() === 1">Previous</button>
484
+
485
+ @if (currentStep() < 3) {
486
+ <button (click)="nextStep()" [disabled]="!canProceed()">Next</button>
487
+ } @else {
488
+ <button (click)="submitWizard()" [disabled]="!allStepsValid()">Submit</button>
489
+ }
490
+ </div>
491
+ </div>
492
+ `,
493
+ })
494
+ class WizardComponent {
495
+ wizardTree = wizardTree;
496
+ currentStep = computed(() => this.wizardTree.$.currentStep());
497
+
498
+ steps = [
499
+ { number: 1, title: 'Personal' },
500
+ { number: 2, title: 'Address' },
501
+ { number: 3, title: 'Preferences' },
502
+ ];
503
+
504
+ // Create forms for each step
505
+ personalForm = this.wizardTree.createForm('steps.personal', {
506
+ firstName: ['', Validators.required],
507
+ lastName: ['', Validators.required],
508
+ email: ['', [Validators.required, Validators.email]],
509
+ phone: ['', Validators.required],
510
+ });
511
+
512
+ addressForm = this.wizardTree.createForm('steps.address', {
513
+ street: ['', Validators.required],
514
+ city: ['', Validators.required],
515
+ state: ['', Validators.required],
516
+ zipCode: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
517
+ });
518
+
519
+ preferencesForm = this.wizardTree.createForm('steps.preferences');
520
+
521
+ // Track form validity
522
+ constructor() {
523
+ // Update validation state when forms change
524
+ effect(() => {
525
+ this.wizardTree.$.validation.personalValid.set(this.personalForm.valid);
526
+ this.wizardTree.$.validation.addressValid.set(this.addressForm.valid);
527
+ this.wizardTree.$.validation.preferencesValid.set(this.preferencesForm.valid);
528
+ });
529
+ }
530
+
531
+ nextStep() {
532
+ if (this.canProceed()) {
533
+ this.wizardTree.$.currentStep.update((step) => step + 1);
534
+ }
535
+ }
536
+
537
+ previousStep() {
538
+ this.wizardTree.$.currentStep.update((step) => Math.max(1, step - 1));
539
+ }
540
+
541
+ canProceed(): boolean {
542
+ const step = this.currentStep();
543
+ const validation = this.wizardTree.$.validation();
544
+
545
+ switch (step) {
546
+ case 1:
547
+ return validation.personalValid;
548
+ case 2:
549
+ return validation.addressValid;
550
+ case 3:
551
+ return validation.preferencesValid;
552
+ default:
553
+ return false;
554
+ }
555
+ }
556
+
557
+ isStepCompleted(stepNumber: number): boolean {
558
+ const validation = this.wizardTree.$.validation();
559
+ switch (stepNumber) {
560
+ case 1:
561
+ return validation.personalValid;
562
+ case 2:
563
+ return validation.addressValid;
564
+ case 3:
565
+ return validation.preferencesValid;
566
+ default:
567
+ return false;
568
+ }
569
+ }
570
+
571
+ allStepsValid(): boolean {
572
+ const validation = this.wizardTree.$.validation();
573
+ return validation.personalValid && validation.addressValid && validation.preferencesValid;
574
+ }
575
+
576
+ submitWizard() {
577
+ if (this.allStepsValid()) {
578
+ const wizardData = this.wizardTree.$.steps();
579
+ console.log('Wizard completed:', wizardData);
580
+ // Submit to API
581
+ }
582
+ }
583
+ }
584
+ ```
585
+
586
+ ### Dynamic Survey Builder
587
+
588
+ ```typescript
589
+ interface Question {
590
+ id: string;
591
+ type: 'text' | 'number' | 'select' | 'radio' | 'checkbox';
592
+ label: string;
593
+ required: boolean;
594
+ options?: string[];
595
+ validation?: {
596
+ min?: number;
597
+ max?: number;
598
+ pattern?: string;
599
+ };
600
+ }
601
+
602
+ interface Survey {
603
+ id: string;
604
+ title: string;
605
+ questions: Question[];
606
+ }
607
+
608
+ interface SurveyResponse {
609
+ surveyId: string;
610
+ responses: Record<string, any>;
611
+ submittedAt?: Date;
612
+ }
613
+
614
+ const surveyTree = signalTree({
615
+ survey: null as Survey | null,
616
+ responses: {} as Record<string, any>,
617
+ currentQuestion: 0,
618
+ isSubmitting: false,
619
+ submitError: null as string | null,
620
+ }).pipe(withForms());
621
+
622
+ @Component({
623
+ template: `
624
+ <div class="survey" *ngIf="survey()">
625
+ <h1>{{ survey()!.title }}</h1>
626
+
627
+ <div class="progress">
628
+ <div class="progress-bar" [style.width.%]="progressPercent()"></div>
629
+ </div>
630
+
631
+ <form [formGroup]="surveyForm" (ngSubmit)="submitSurvey()">
632
+ @for (question of survey()!.questions; track question.id; let i = $index) {
633
+ <div class="question" [class.active]="currentQuestion() === i">
634
+ <h3>{{ question.label }}</h3>
635
+
636
+ @switch (question.type) { @case ('text') {
637
+ <input [formControlName]="question.id" type="text" [placeholder]="question.label" />
638
+ } @case ('number') {
639
+ <input [formControlName]="question.id" type="number" [min]="question.validation?.min" [max]="question.validation?.max" />
640
+ } @case ('select') {
641
+ <select [formControlName]="question.id">
642
+ <option value="">Select an option</option>
643
+ @for (option of question.options; track option) {
644
+ <option [value]="option">{{ option }}</option>
645
+ }
646
+ </select>
647
+ } @case ('radio') { @for (option of question.options; track option) {
648
+ <label>
649
+ <input [formControlName]="question.id" type="radio" [value]="option" />
650
+ {{ option }}
651
+ </label>
652
+ } } @case ('checkbox') { @for (option of question.options; track option) {
653
+ <label>
654
+ <input type="checkbox" [value]="option" (change)="onCheckboxChange(question.id, option, $event)" />
655
+ {{ option }}
656
+ </label>
657
+ } } }
658
+
659
+ <div class="validation-error" *ngIf="getQuestionError(question.id)">
660
+ {{ getQuestionError(question.id) }}
661
+ </div>
662
+ </div>
663
+ }
664
+
665
+ <div class="survey-navigation">
666
+ <button type="button" (click)="previousQuestion()" [disabled]="currentQuestion() === 0">Previous</button>
667
+
668
+ @if (currentQuestion() < survey()!.questions.length - 1) {
669
+ <button type="button" (click)="nextQuestion()" [disabled]="!isCurrentQuestionValid()">Next</button>
670
+ } @else {
671
+ <button type="submit" [disabled]="surveyForm.invalid || isSubmitting()">
672
+ {{ isSubmitting() ? 'Submitting...' : 'Submit Survey' }}
673
+ </button>
674
+ }
675
+ </div>
676
+ </form>
677
+
678
+ <div class="error" *ngIf="submitError()">
679
+ {{ submitError() }}
680
+ </div>
681
+ </div>
682
+ `,
683
+ })
684
+ class SurveyComponent implements OnInit {
685
+ surveyTree = surveyTree;
686
+ surveyForm!: FormGroup;
687
+
688
+ survey = computed(() => this.surveyTree.$.survey());
689
+ currentQuestion = computed(() => this.surveyTree.$.currentQuestion());
690
+ isSubmitting = computed(() => this.surveyTree.$.isSubmitting());
691
+ submitError = computed(() => this.surveyTree.$.submitError());
692
+
693
+ progressPercent = computed(() => {
694
+ const survey = this.survey();
695
+ const current = this.currentQuestion();
696
+ if (!survey) return 0;
697
+ return ((current + 1) / survey.questions.length) * 100;
698
+ });
699
+
700
+ ngOnInit() {
701
+ // Load survey and create form
702
+ this.loadSurvey().then((survey) => {
703
+ this.surveyTree.$.survey.set(survey);
704
+ this.createSurveyForm(survey);
705
+ });
706
+ }
707
+
708
+ private createSurveyForm(survey: Survey) {
709
+ const formConfig: Record<string, any> = {};
710
+
711
+ survey.questions.forEach((question) => {
712
+ const validators = [];
713
+
714
+ if (question.required) {
715
+ validators.push(Validators.required);
716
+ }
717
+
718
+ if (question.validation) {
719
+ if (question.validation.min !== undefined) {
720
+ validators.push(Validators.min(question.validation.min));
721
+ }
722
+ if (question.validation.max !== undefined) {
723
+ validators.push(Validators.max(question.validation.max));
724
+ }
725
+ if (question.validation.pattern) {
726
+ validators.push(Validators.pattern(question.validation.pattern));
727
+ }
728
+ }
729
+
730
+ const defaultValue = question.type === 'checkbox' ? [] : '';
731
+ formConfig[question.id] = [defaultValue, validators];
732
+ });
733
+
734
+ this.surveyForm = this.surveyTree.createForm('responses', formConfig);
735
+ }
736
+
737
+ nextQuestion() {
738
+ if (this.isCurrentQuestionValid()) {
739
+ this.surveyTree.$.currentQuestion.update((q) => q + 1);
740
+ }
741
+ }
742
+
743
+ previousQuestion() {
744
+ this.surveyTree.$.currentQuestion.update((q) => Math.max(0, q - 1));
745
+ }
746
+
747
+ isCurrentQuestionValid(): boolean {
748
+ const survey = this.survey();
749
+ const current = this.currentQuestion();
750
+ if (!survey) return false;
751
+
752
+ const question = survey.questions[current];
753
+ const control = this.surveyForm.get(question.id);
754
+ return control ? control.valid : false;
755
+ }
756
+
757
+ getQuestionError(questionId: string): string | null {
758
+ const control = this.surveyForm.get(questionId);
759
+ if (control?.errors && control.touched) {
760
+ if (control.errors['required']) return 'This question is required';
761
+ if (control.errors['min']) return `Minimum value is ${control.errors['min'].min}`;
762
+ if (control.errors['max']) return `Maximum value is ${control.errors['max'].max}`;
763
+ if (control.errors['pattern']) return 'Invalid format';
764
+ }
765
+ return null;
766
+ }
767
+
768
+ onCheckboxChange(questionId: string, option: string, event: any) {
769
+ const control = this.surveyForm.get(questionId);
770
+ const currentValue = control?.value || [];
771
+
772
+ if (event.target.checked) {
773
+ control?.setValue([...currentValue, option]);
774
+ } else {
775
+ control?.setValue(currentValue.filter((v: string) => v !== option));
776
+ }
777
+ }
778
+
779
+ async submitSurvey() {
780
+ if (this.surveyForm.valid) {
781
+ this.surveyTree.$.isSubmitting.set(true);
782
+ this.surveyTree.$.submitError.set(null);
783
+
784
+ try {
785
+ const response: SurveyResponse = {
786
+ surveyId: this.survey()!.id,
787
+ responses: this.surveyTree.$.responses(),
788
+ submittedAt: new Date(),
789
+ };
790
+
791
+ await this.submitSurveyResponse(response);
792
+ console.log('Survey submitted successfully');
793
+ } catch (error) {
794
+ this.surveyTree.$.submitError.set('Failed to submit survey. Please try again.');
795
+ } finally {
796
+ this.surveyTree.$.isSubmitting.set(false);
797
+ }
798
+ }
799
+ }
800
+
801
+ private async loadSurvey(): Promise<Survey> {
802
+ // Load survey from API
803
+ return {
804
+ id: '1',
805
+ title: 'Customer Satisfaction Survey',
806
+ questions: [
807
+ {
808
+ id: 'q1',
809
+ type: 'radio',
810
+ label: 'How satisfied are you with our service?',
811
+ required: true,
812
+ options: ['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied', 'Very Dissatisfied'],
813
+ },
814
+ {
815
+ id: 'q2',
816
+ type: 'text',
817
+ label: 'What can we improve?',
818
+ required: false,
819
+ },
820
+ {
821
+ id: 'q3',
822
+ type: 'number',
823
+ label: 'Rate us from 1-10',
824
+ required: true,
825
+ validation: { min: 1, max: 10 },
826
+ },
827
+ ],
828
+ };
829
+ }
830
+
831
+ private async submitSurveyResponse(response: SurveyResponse): Promise<void> {
832
+ // Submit to API
833
+ await new Promise((resolve) => setTimeout(resolve, 1000));
834
+ }
835
+ }
836
+ ```
837
+
838
+ ## 🎯 When to Use ng-forms
839
+
840
+ Perfect for:
841
+
842
+ - ✅ Angular applications with complex forms
843
+ - ✅ Real-time form validation and feedback
844
+ - ✅ Multi-step forms and wizards
845
+ - ✅ Dynamic form generation
846
+ - ✅ Forms with cross-field validation
847
+ - ✅ Applications requiring form state persistence
848
+
849
+ ## 🔗 Composition with Other Packages
850
+
851
+ ```typescript
852
+ import { signalTree } from '@signaltree/core';
853
+ import { withForms } from '@signaltree/ng-forms';
854
+ import { withValidation } from '@signaltree/validation';
855
+ import { withDevtools } from '@signaltree/devtools';
856
+
857
+ const tree = signalTree(state).pipe(
858
+ withValidation(), // Enhanced validation rules
859
+ withForms(), // Angular forms integration
860
+ withDevtools() // Debug form state changes
861
+ );
862
+ ```
863
+
864
+ ## 📈 Performance Benefits
865
+
866
+ - **Automatic synchronization** between forms and state
867
+ - **Debounced updates** prevent excessive state changes
868
+ - **Efficient validation** with minimal re-computation
869
+ - **Tree-shakeable** - only includes what you use
870
+ - **Memory efficient** form state management
871
+
872
+ ## 🔗 Links
873
+
874
+ - [SignalTree Documentation](https://signaltree.io)
875
+ - [Core Package](https://www.npmjs.com/package/@signaltree/core)
876
+ - [GitHub Repository](https://github.com/JBorgia/signaltree)
877
+ - [ng-forms Examples](https://signaltree.io/examples/ng-forms)
878
+
879
+ ## 📄 License
880
+
881
+ MIT License with AI Training Restriction - see the [LICENSE](../../LICENSE) file for details.
882
+
883
+ ---
884
+
885
+ **Seamless Angular Forms** with SignalTree power! 🅰️
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@signaltree/ng-forms",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
+ "description": "Complete Angular forms integration for SignalTree. FormTree creation, custom directives, validators, form state tracking, and RxJS bridge.",
4
5
  "peerDependencies": {
5
6
  "@angular/core": "^20.1.0",
6
7
  "@angular/forms": "^20.1.0",