ngx-vest-forms 1.3.0 → 1.4.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.
@@ -1,9 +1,322 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, Directive, isDevMode, inject, DestroyRef, effect, ChangeDetectorRef, signal, computed, ContentChild, ChangeDetectionStrategy, Component, Optional } from '@angular/core';
3
- import { FormGroup, FormArray, NG_ASYNC_VALIDATORS, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, NgModelGroup, NgModel, ControlContainer, FormsModule } from '@angular/forms';
4
- import { Subject, of, ReplaySubject, debounceTime, take, switchMap, Observable, takeUntil, filter, map, distinctUntilChanged, startWith, tap, finalize, mergeWith } from 'rxjs';
2
+ import { contentChild, inject, Injector, computed, signal, effect, afterEveryRender, Directive, InjectionToken, input, ChangeDetectionStrategy, Component, isDevMode, DestroyRef, Optional } from '@angular/core';
3
+ import { NgModel, NgModelGroup, NgForm, FormGroup, FormArray, NG_ASYNC_VALIDATORS, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, ControlContainer, FormsModule } from '@angular/forms';
4
+ import { Subject, of, ReplaySubject, debounceTime, take, switchMap, Observable, takeUntil, filter, map, distinctUntilChanged, startWith, tap, finalize, catchError, from } from 'rxjs';
5
5
  import { outputFromObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
6
 
7
+ function getInitialFormControlState() {
8
+ return {
9
+ status: 'INVALID',
10
+ isValid: false,
11
+ isInvalid: true,
12
+ isPending: false,
13
+ isDisabled: false,
14
+ isTouched: false,
15
+ isDirty: false,
16
+ isPristine: true,
17
+ errors: null,
18
+ };
19
+ }
20
+ class FormControlStateDirective {
21
+ #hostNgModel;
22
+ #hostNgModelGroup;
23
+ #injector;
24
+ /**
25
+ * Computed signal for the active control (NgModel or NgModelGroup)
26
+ */
27
+ #activeControl;
28
+ /**
29
+ * Internal signal for robust touched/dirty tracking (syncs after every render)
30
+ */
31
+ #interactionState;
32
+ /**
33
+ * Internal signal for control state (updated reactively)
34
+ */
35
+ #controlStateSignal;
36
+ constructor() {
37
+ this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
38
+ this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : []));
39
+ this.#hostNgModel = inject(NgModel, {
40
+ self: true,
41
+ optional: true,
42
+ });
43
+ this.#hostNgModelGroup = inject(NgModelGroup, {
44
+ self: true,
45
+ optional: true,
46
+ });
47
+ this.#injector = inject(Injector);
48
+ /**
49
+ * Computed signal for the active control (NgModel or NgModelGroup)
50
+ */
51
+ this.#activeControl = computed(() => {
52
+ return (this.#hostNgModel ||
53
+ this.#hostNgModelGroup ||
54
+ this.contentNgModel() ||
55
+ this.contentNgModelGroup() ||
56
+ null);
57
+ }, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
58
+ /**
59
+ * Internal signal for robust touched/dirty tracking (syncs after every render)
60
+ */
61
+ this.#interactionState = signal({
62
+ isTouched: false,
63
+ isDirty: false,
64
+ }, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
65
+ /**
66
+ * Internal signal for control state (updated reactively)
67
+ */
68
+ this.#controlStateSignal = signal(getInitialFormControlState(), ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : []));
69
+ /**
70
+ * Main control state computed signal (merges robust touched/dirty)
71
+ */
72
+ this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
73
+ /**
74
+ * Extracts error messages from Angular/Vest errors (recursively flattens)
75
+ */
76
+ this.errorMessages = computed(() => {
77
+ const state = this.controlState();
78
+ if (!state?.errors)
79
+ return [];
80
+ // Vest errors are stored in the 'errors' property as an array
81
+ const vestErrors = state.errors['errors'];
82
+ if (Array.isArray(vestErrors)) {
83
+ return vestErrors;
84
+ }
85
+ // Fallback to flattened Angular error keys
86
+ return this.#flattenAngularErrors(state.errors);
87
+ }, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
88
+ /**
89
+ * ADVANCED: updateOn strategy (change/blur/submit) if available
90
+ */
91
+ this.updateOn = computed(() => {
92
+ const ngModel = this.contentNgModel() || this.#hostNgModel;
93
+ // Angular's NgModel.options?.updateOn
94
+ return ngModel?.options?.updateOn ?? 'change';
95
+ }, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
96
+ /**
97
+ * ADVANCED: Composite/derived signals for advanced error display logic
98
+ */
99
+ this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
100
+ this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
101
+ this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
102
+ /**
103
+ * Extracts warning messages from Vest validation results (robust)
104
+ */
105
+ this.warningMessages = computed(() => {
106
+ const state = this.controlState();
107
+ if (!state?.errors)
108
+ return [];
109
+ const warnings = state.errors['warnings'];
110
+ if (Array.isArray(warnings)) {
111
+ return warnings;
112
+ }
113
+ // Optionally, flatten nested warnings if needed in future
114
+ return [];
115
+ }, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
116
+ /**
117
+ * Whether async validation is in progress
118
+ */
119
+ this.hasPendingValidation = computed(() => !!this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
120
+ /**
121
+ * Convenience signals for common state checks
122
+ */
123
+ this.isValid = computed(() => this.controlState().isValid || false, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
124
+ this.isInvalid = computed(() => this.controlState().isInvalid || false, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
125
+ this.isPending = computed(() => this.controlState().isPending || false, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
126
+ this.isTouched = computed(() => this.controlState().isTouched || false, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
127
+ this.isDirty = computed(() => this.controlState().isDirty || false, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
128
+ this.isPristine = computed(() => this.controlState().isPristine || false, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
129
+ this.isDisabled = computed(() => this.controlState().isDisabled || false, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
130
+ this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : []));
131
+ // Update control state reactively
132
+ effect(() => {
133
+ const control = this.#activeControl();
134
+ const interaction = this.#interactionState();
135
+ if (!control) {
136
+ this.#controlStateSignal.set(getInitialFormControlState());
137
+ return;
138
+ }
139
+ // Listen to control changes
140
+ const sub = control.control?.statusChanges?.subscribe(() => {
141
+ const { status, valid, invalid, pending, disabled, pristine, errors } = control;
142
+ this.#controlStateSignal.set({
143
+ status,
144
+ isValid: valid,
145
+ isInvalid: invalid,
146
+ isPending: pending,
147
+ isDisabled: disabled,
148
+ isTouched: interaction.isTouched,
149
+ isDirty: interaction.isDirty,
150
+ isPristine: pristine,
151
+ errors,
152
+ });
153
+ });
154
+ // Initial update
155
+ const { status, valid, invalid, pending, disabled, pristine, errors } = control;
156
+ this.#controlStateSignal.set({
157
+ status,
158
+ isValid: valid,
159
+ isInvalid: invalid,
160
+ isPending: pending,
161
+ isDisabled: disabled,
162
+ isTouched: interaction.isTouched,
163
+ isDirty: interaction.isDirty,
164
+ isPristine: pristine,
165
+ errors,
166
+ });
167
+ return () => sub?.unsubscribe();
168
+ });
169
+ // Robustly sync touched/dirty after every render (Angular 18+ best practice)
170
+ afterEveryRender(() => {
171
+ const control = this.#activeControl();
172
+ if (control) {
173
+ const current = this.#interactionState();
174
+ const newTouched = control.touched ?? false;
175
+ const newDirty = control.dirty ?? false;
176
+ if (newTouched !== current.isTouched ||
177
+ newDirty !== current.isDirty) {
178
+ this.#interactionState.set({
179
+ isTouched: newTouched,
180
+ isDirty: newDirty,
181
+ });
182
+ }
183
+ }
184
+ }, { injector: this.#injector });
185
+ }
186
+ /**
187
+ * Recursively flattens Angular error objects into an array of error keys.
188
+ */
189
+ #flattenAngularErrors(errors) {
190
+ const result = [];
191
+ for (const key of Object.keys(errors)) {
192
+ const value = errors[key];
193
+ if (typeof value === 'object' && value !== null) {
194
+ result.push(...this.#flattenAngularErrors(value));
195
+ }
196
+ else {
197
+ result.push(key);
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
203
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "20.3.1", type: FormControlStateDirective, isStandalone: true, selector: "[formControlState]", queries: [{ propertyName: "contentNgModel", first: true, predicate: NgModel, descendants: true, isSignal: true }, { propertyName: "contentNgModelGroup", first: true, predicate: NgModelGroup, descendants: true, isSignal: true }], exportAs: ["formControlState"], ngImport: i0 }); }
204
+ }
205
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: FormControlStateDirective, decorators: [{
206
+ type: Directive,
207
+ args: [{
208
+ selector: '[formControlState]',
209
+ exportAs: 'formControlState',
210
+ }]
211
+ }], ctorParameters: () => [] });
212
+
213
+ const SC_ERROR_DISPLAY_MODE_TOKEN = new InjectionToken('SC_ERROR_DISPLAY_MODE_TOKEN', {
214
+ providedIn: 'root',
215
+ factory: () => 'on-blur-or-submit',
216
+ });
217
+
218
+ const SC_ERROR_DISPLAY_MODE_DEFAULT = 'on-blur-or-submit';
219
+ class FormErrorDisplayDirective {
220
+ #formControlState;
221
+ // Optionally inject NgForm for form submission tracking
222
+ #ngForm;
223
+ constructor() {
224
+ this.#formControlState = inject(FormControlStateDirective);
225
+ // Optionally inject NgForm for form submission tracking
226
+ this.#ngForm = inject(NgForm, { optional: true });
227
+ // Use DI token for global default, fallback to hardcoded default
228
+ this.errorDisplayMode = input(inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
229
+ SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
230
+ // Expose state signals from FormControlStateDirective
231
+ this.controlState = this.#formControlState.controlState;
232
+ this.errorMessages = this.#formControlState.errorMessages;
233
+ this.warningMessages = this.#formControlState.warningMessages;
234
+ this.hasPendingValidation = this.#formControlState.hasPendingValidation;
235
+ this.isTouched = this.#formControlState.isTouched;
236
+ this.isDirty = this.#formControlState.isDirty;
237
+ this.isValid = this.#formControlState.isValid;
238
+ this.isInvalid = this.#formControlState.isInvalid;
239
+ /**
240
+ * Expose updateOn and formSubmitted as public signals for advanced consumers.
241
+ * updateOn: The ngModelOptions.updateOn value for the control (change/blur/submit)
242
+ * formSubmitted: true after the form is submitted (if NgForm is present)
243
+ */
244
+ this.updateOn = this.#formControlState.updateOn;
245
+ // formSubmitted signal is already public
246
+ // Signal for form submission state (true after submit)
247
+ this.formSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "formSubmitted" }] : []));
248
+ /**
249
+ * Determines if errors should be shown based on the specified display mode
250
+ * and the control's state (touched/submitted).
251
+ */
252
+ this.shouldShowErrors = computed(() => {
253
+ const mode = this.errorDisplayMode();
254
+ const isTouched = this.isTouched();
255
+ const hasErrors = this.errorMessages().length > 0;
256
+ const updateOn = this.updateOn();
257
+ const formSubmitted = this.formSubmitted();
258
+ // Always only show errors after submit if updateOn is 'submit'
259
+ if (updateOn === 'submit') {
260
+ return !!(formSubmitted && hasErrors);
261
+ }
262
+ // on-blur: show errors after blur (touch)
263
+ if (mode === 'on-blur') {
264
+ return !!(isTouched && hasErrors);
265
+ }
266
+ // on-submit: show errors after submit
267
+ if (mode === 'on-submit') {
268
+ return !!(formSubmitted && hasErrors);
269
+ }
270
+ // on-blur-or-submit: show errors after blur (touch) or submit
271
+ return !!((isTouched || formSubmitted) && hasErrors);
272
+ }, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
273
+ /**
274
+ * Errors to display (filtered for pending state)
275
+ */
276
+ this.errors = computed(() => {
277
+ if (this.hasPendingValidation())
278
+ return [];
279
+ return this.errorMessages();
280
+ }, ...(ngDevMode ? [{ debugName: "errors" }] : []));
281
+ /**
282
+ * Warnings to display (filtered for pending state)
283
+ */
284
+ this.warnings = computed(() => {
285
+ if (this.hasPendingValidation())
286
+ return [];
287
+ return this.warningMessages();
288
+ }, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
289
+ /**
290
+ * Whether the control is currently being validated (pending)
291
+ */
292
+ this.isPending = computed(() => this.hasPendingValidation(), ...(ngDevMode ? [{ debugName: "isPending" }] : []));
293
+ // Listen for form submit event if NgForm is present
294
+ if (this.#ngForm?.ngSubmit) {
295
+ this.#ngForm.ngSubmit.subscribe(() => {
296
+ this.formSubmitted.set(true);
297
+ });
298
+ }
299
+ // Warn about problematic combinations of updateOn and errorDisplayMode
300
+ effect(() => {
301
+ const mode = this.errorDisplayMode();
302
+ const updateOn = this.updateOn();
303
+ if (updateOn === 'submit' && mode === 'on-blur') {
304
+ console.warn('[ngx-vest-forms] Potential UX issue: errorDisplayMode is "on-blur" but updateOn is "submit". Errors will only show after form submission, not after blur.');
305
+ }
306
+ });
307
+ }
308
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
309
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.1", type: FormErrorDisplayDirective, isStandalone: true, selector: "[formErrorDisplay]", inputs: { errorDisplayMode: { classPropertyName: "errorDisplayMode", publicName: "errorDisplayMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorDisplay"], hostDirectives: [{ directive: FormControlStateDirective }], ngImport: i0 }); }
310
+ }
311
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
312
+ type: Directive,
313
+ args: [{
314
+ selector: '[formErrorDisplay]',
315
+ exportAs: 'formErrorDisplay',
316
+ hostDirectives: [FormControlStateDirective],
317
+ }]
318
+ }], ctorParameters: () => [] });
319
+
7
320
  const ROOT_FORM = 'rootForm';
8
321
  /**
9
322
  * Debounce time in milliseconds for validation config dependency triggering.
@@ -258,7 +571,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
258
571
  type: Directive,
259
572
  args: [{
260
573
  selector: 'form[validateRootForm][formValue][suite]',
261
- standalone: true,
262
574
  providers: [
263
575
  {
264
576
  provide: NG_ASYNC_VALIDATORS,
@@ -269,6 +581,72 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
269
581
  }]
270
582
  }] });
271
583
 
584
+ /**
585
+ * Accessible ScControlWrapper
586
+ *
587
+ * Usage:
588
+ * - Wrap any form element with `sc-control-wrapper` or `[scControlWrapper]` (or `[sc-control-wrapper]` for legacy) that contains an `ngModel` or `ngModelGroup`.
589
+ * - Errors and warnings are shown when the control is invalid and touched, after form submit, or both, depending on the error display mode.
590
+ * - Pending state is shown with a spinner and aria-busy when async validation is running.
591
+ * - No manual error/warning/pending signal management is needed in your form components.
592
+ *
593
+ * Error & Warning Display Behavior:
594
+ * - The error display mode can be configured globally using the SC_ERROR_DISPLAY_MODE_TOKEN injection token (import from core), or per instance using the `errorDisplayMode` input on FormErrorDisplayDirective (which this component uses as a hostDirective).
595
+ * - Possible values: 'on-blur' | 'on-submit' | 'on-blur-or-submit' (default: 'on-blur-or-submit')
596
+ *
597
+ * Example (per instance):
598
+ * <div ngxControlWrapper>
599
+ * <label>
600
+ * <span>First name</span>
601
+ * <input type="text" name="firstName" [ngModel]="formValue().firstName" />
602
+ * </label>
603
+ * </div>
604
+ * /// To customize errorDisplayMode for this instance, use the errorDisplayMode input.
605
+ *
606
+ * Example (with warnings and pending):
607
+ * <sc-control-wrapper>
608
+ * <input name="username" ngModel />
609
+ * </sc-control-wrapper>
610
+ * /// If async validation is running, a spinner and 'Validating…' will be shown.
611
+ * /// If Vest warnings are present, they will be shown below errors.
612
+ *
613
+ * Example (global config):
614
+ * import { provide } from '@angular/core';
615
+ * import { SC_ERROR_DISPLAY_MODE_TOKEN } from 'ngx-vest-forms';
616
+ * @Component({
617
+ * providers: [
618
+ * provide(SC_ERROR_DISPLAY_MODE_TOKEN, { useValue: 'submit' })
619
+ * ]
620
+ * })
621
+ * export class MyComponent {}
622
+ *
623
+ * Best Practices:
624
+ * - Use for every input or group in your forms.
625
+ * - Do not manually display errors for individual fields; rely on this wrapper.
626
+ */
627
+ class ControlWrapperComponent {
628
+ constructor() {
629
+ this.errorDisplay = inject(FormErrorDisplayDirective, {
630
+ self: true,
631
+ });
632
+ }
633
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
634
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.1", type: ControlWrapperComponent, isStandalone: true, selector: "sc-control-wrapper, [scControlWrapper], [sc-control-wrapper]", host: { properties: { "class.sc-control-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "sc-control-wrapper" }, hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-control-wrapper\">\n <div class=\"sc-control-wrapper__content\">\n <ng-content />\n </div>\n\n @if (errorDisplay.shouldShowErrors()) {\n <div class=\"text-sm text-red-600\" role=\"alert\" aria-live=\"polite\">\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error.message || error }}</li>\n }\n </ul>\n </div>\n }\n @if (errorDisplay.warnings().length > 0) {\n <div class=\"text-sm text-yellow-700\" role=\"status\" aria-live=\"polite\">\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n </div>\n }\n @if (errorDisplay.isPending()) {\n <div class=\"flex items-center gap-1 text-xs text-gray-500\" aria-busy=\"true\">\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n ></span>\n Validating\u2026\n </div>\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
635
+ }
636
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: ControlWrapperComponent, decorators: [{
637
+ type: Component,
638
+ args: [{ selector: 'sc-control-wrapper, [scControlWrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
639
+ class: 'sc-control-wrapper',
640
+ '[class.sc-control-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
641
+ '[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
642
+ }, hostDirectives: [
643
+ {
644
+ directive: FormErrorDisplayDirective,
645
+ inputs: ['errorDisplayMode'],
646
+ },
647
+ ], template: "<div class=\"ngx-control-wrapper\">\n <div class=\"sc-control-wrapper__content\">\n <ng-content />\n </div>\n\n @if (errorDisplay.shouldShowErrors()) {\n <div class=\"text-sm text-red-600\" role=\"alert\" aria-live=\"polite\">\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error.message || error }}</li>\n }\n </ul>\n </div>\n }\n @if (errorDisplay.warnings().length > 0) {\n <div class=\"text-sm text-yellow-700\" role=\"status\" aria-live=\"polite\">\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n </div>\n }\n @if (errorDisplay.isPending()) {\n <div class=\"flex items-center gap-1 text-xs text-gray-500\" aria-busy=\"true\">\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n ></span>\n Validating\u2026\n </div>\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"] }]
648
+ }] });
649
+
272
650
  /**
273
651
  * Type guard to check if a value is a primitive type.
274
652
  *
@@ -538,6 +916,16 @@ function validateFormValue(formValue, shape, path = '') {
538
916
  }
539
917
 
540
918
  class FormDirective {
919
+ /**
920
+ * Returns the current form state: validity and errors.
921
+ * Used by templates and tests as vestForm.formState().valid/errors
922
+ */
923
+ formState() {
924
+ return {
925
+ valid: this.ngForm.form.valid,
926
+ errors: getAllFormErrors(this.ngForm.form),
927
+ };
928
+ }
541
929
  constructor() {
542
930
  this.ngForm = inject(NgForm, { self: true, optional: false });
543
931
  this.destroyRef = inject(DestroyRef);
@@ -613,17 +1001,21 @@ class FormDirective {
613
1001
  /**
614
1002
  * Used to debounce formValues to make sure vest isn't triggered all the time
615
1003
  */
1004
+ // Async validator cache for debouncing and single-flight per field
616
1005
  this.formValueCache = {};
617
1006
  this.validationInProgress = new Set();
618
1007
  /**
619
1008
  * Trigger shape validations if the form gets updated
620
1009
  * This is how we can throw run-time errors
621
1010
  */
622
- this.formValueChange.subscribe((v) => {
623
- if (this.formShape()) {
624
- validateShape(v, this.formShape());
625
- }
626
- });
1011
+ if (isDevMode()) {
1012
+ effect(() => {
1013
+ const v = this.formValue();
1014
+ if (v && this.formShape()) {
1015
+ validateShape(v, this.formShape());
1016
+ }
1017
+ });
1018
+ }
627
1019
  /**
628
1020
  * Mark all the fields as touched when the form is submitted
629
1021
  */
@@ -749,35 +1141,109 @@ class FormDirective {
749
1141
  * @param validationOptions
750
1142
  * @returns an asynchronous validator function
751
1143
  */
1144
+ /**
1145
+ * Improved async validator: composable, debounced, robust error handling.
1146
+ * Compatible with v2 pattern, non-breaking.
1147
+ */
752
1148
  createAsyncValidator(field, validationOptions) {
753
- if (!this.suite()) {
754
- return () => of(null);
755
- }
756
1149
  return (value) => {
757
- if (!this.formValue()) {
1150
+ const suite = this.suite();
1151
+ const formValue = this.formValue();
1152
+ if (!suite || !formValue) {
758
1153
  return of(null);
759
1154
  }
760
- const mod = cloneDeep(this.formValue());
761
- set(mod, field, value); // Update the property with path
1155
+ const candidate = cloneDeep(formValue);
1156
+ set(candidate, field, value);
762
1157
  if (!this.formValueCache[field]) {
763
- this.formValueCache[field] = {
764
- sub$$: new ReplaySubject(1), // Keep track of the last model
765
- };
766
- this.formValueCache[field].debounced = this.formValueCache[field].sub$$.pipe(debounceTime(validationOptions.debounceTime));
1158
+ this.formValueCache[field] = new ReplaySubject(1);
767
1159
  }
768
- // Next the latest model in the cache for a certain field
769
- this.formValueCache[field].sub$$.next(mod);
770
- return this.formValueCache[field].debounced.pipe(
771
- // When debounced, take the latest value and perform the asynchronous vest validation
772
- take(1), switchMap(() => {
1160
+ const subject = this.formValueCache[field];
1161
+ subject.next(candidate);
1162
+ const debounceMs = validationOptions?.debounceTime ?? 0;
1163
+ return subject.pipe(debounceTime(debounceMs), take(1), switchMap((model) => {
1164
+ if (isDevMode()) {
1165
+ console.debug('[ngx-vest-forms] Running suite for field', field, 'with model', model);
1166
+ }
773
1167
  return new Observable((observer) => {
774
- this.suite()(mod, field).done((result) => {
775
- const errors = result.getErrors()[field];
776
- observer.next(errors ? { error: errors[0], errors } : null);
1168
+ const emitResult = (vestResult) => {
1169
+ const pushResult = () => {
1170
+ try {
1171
+ let errors;
1172
+ if (typeof vestResult?.getErrors === 'function') {
1173
+ const direct = vestResult.getErrors(field);
1174
+ if (Array.isArray(direct)) {
1175
+ errors = direct;
1176
+ }
1177
+ else {
1178
+ const bag = vestResult.getErrors();
1179
+ errors = bag?.[field];
1180
+ }
1181
+ }
1182
+ if (isDevMode()) {
1183
+ console.debug('[ngx-vest-forms] Final result errors for field', field, errors);
1184
+ }
1185
+ if (Array.isArray(errors) && errors.length > 0) {
1186
+ observer.next({ error: errors[0], errors });
1187
+ }
1188
+ else {
1189
+ observer.next(null);
1190
+ }
1191
+ }
1192
+ catch (error) {
1193
+ observer.next({
1194
+ vestInternalError: error instanceof Error
1195
+ ? error.message
1196
+ : 'Unknown validation error',
1197
+ });
1198
+ }
1199
+ finally {
1200
+ observer.complete();
1201
+ }
1202
+ };
1203
+ if (typeof queueMicrotask === 'function') {
1204
+ queueMicrotask(pushResult);
1205
+ }
1206
+ else {
1207
+ setTimeout(pushResult, 0);
1208
+ }
1209
+ };
1210
+ let suiteResult;
1211
+ try {
1212
+ suiteResult = suite(model, field);
1213
+ }
1214
+ catch (error) {
1215
+ observer.next({
1216
+ vestInternalError: error instanceof Error
1217
+ ? error.message
1218
+ : 'Unknown validation error',
1219
+ });
777
1220
  observer.complete();
778
- });
1221
+ return;
1222
+ }
1223
+ if (isDevMode()) {
1224
+ console.debug('[ngx-vest-forms] Suite returned', suiteResult);
1225
+ }
1226
+ const doneFn = suiteResult?.done;
1227
+ if (typeof doneFn === 'function') {
1228
+ try {
1229
+ doneFn.call(suiteResult, emitResult);
1230
+ }
1231
+ catch (error) {
1232
+ observer.next({
1233
+ vestInternalError: error instanceof Error
1234
+ ? error.message
1235
+ : 'Unknown validation error',
1236
+ });
1237
+ observer.complete();
1238
+ }
1239
+ }
1240
+ else {
1241
+ emitResult(suiteResult);
1242
+ }
779
1243
  });
780
- }), takeUntilDestroyed(this.destroyRef));
1244
+ }), catchError((err) => of({
1245
+ vestInternalError: err?.message || 'Unknown validation error',
1246
+ })), takeUntilDestroyed(this.destroyRef));
781
1247
  };
782
1248
  }
783
1249
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
@@ -788,80 +1254,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
788
1254
  args: [{
789
1255
  selector: 'form[scVestForm]',
790
1256
  exportAs: 'scVestForm',
791
- standalone: true,
792
1257
  }]
793
1258
  }], ctorParameters: () => [] });
794
1259
 
795
- class ControlWrapperComponent {
796
- constructor() {
797
- this.ngModelGroup = inject(NgModelGroup, {
798
- optional: true,
799
- self: true,
800
- });
801
- this.destroy$$ = new Subject();
802
- this.cdRef = inject(ChangeDetectorRef);
803
- this.formDirective = inject(FormDirective);
804
- // Signal-based state management
805
- this.controlState = signal({ touched: false, pending: false, errors: undefined }, ...(ngDevMode ? [{ debugName: "controlState" }] : []));
806
- // Computed signal for invalid state
807
- this.invalid = computed(() => {
808
- const state = this.controlState();
809
- return state.touched && state.errors && state.errors.length > 0;
810
- }, ...(ngDevMode ? [{ debugName: "invalid" }] : []));
811
- // Computed signal for errors with pending state handling
812
- this.errors = computed(() => {
813
- const state = this.controlState();
814
- if (state.pending) {
815
- return this.previousError;
816
- }
817
- else {
818
- this.previousError = state.errors;
819
- }
820
- return state.errors;
821
- }, ...(ngDevMode ? [{ debugName: "errors" }] : []));
822
- }
823
- get control() {
824
- return this.ngModelGroup
825
- ? this.ngModelGroup.control
826
- : this.ngModel?.control;
827
- }
828
- ngOnDestroy() {
829
- this.destroy$$.next();
830
- }
831
- ngAfterViewInit() {
832
- // Wait until the form is idle
833
- // Then, listen to all events of the ngModelGroup or ngModel
834
- // and update our signal-based state
835
- this.formDirective.idle$
836
- .pipe(switchMap(() => this.ngModelGroup?.control?.events || of(null)), mergeWith(this.control?.events || of(null)), takeUntil(this.destroy$$))
837
- .subscribe(() => {
838
- this.updateControlState();
839
- this.cdRef.markForCheck();
840
- });
841
- }
842
- updateControlState() {
843
- const control = this.control;
844
- if (control) {
845
- this.controlState.set({
846
- touched: control.touched,
847
- pending: control.pending,
848
- errors: control.errors?.['errors'],
849
- });
850
- }
851
- }
852
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
853
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.1", type: ControlWrapperComponent, isStandalone: true, selector: "[sc-control-wrapper]", host: { properties: { "class.sc-control-wrapper--invalid": "invalid()" } }, queries: [{ propertyName: "ngModel", first: true, predicate: NgModel, descendants: true }], ngImport: i0, template: "<div class=\"sc-control-wrapper\">\n <div class=\"sc-control-wrapper__content\">\n <ng-content></ng-content>\n </div>\n <div class=\"sc-control-wrapper__errors\">\n <ul [hidden]=\"!invalid()\">\n @for (error of errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n </div>\n</div>\n", styles: [":host{display:block;position:relative}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
854
- }
855
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: ControlWrapperComponent, decorators: [{
856
- type: Component,
857
- args: [{ selector: '[sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
858
- '[class.sc-control-wrapper--invalid]': 'invalid()',
859
- }, template: "<div class=\"sc-control-wrapper\">\n <div class=\"sc-control-wrapper__content\">\n <ng-content></ng-content>\n </div>\n <div class=\"sc-control-wrapper__errors\">\n <ul [hidden]=\"!invalid()\">\n @for (error of errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n </div>\n</div>\n", styles: [":host{display:block;position:relative}\n"] }]
860
- }], propDecorators: { ngModel: [{
861
- type: ContentChild,
862
- args: [NgModel]
863
- }] } });
864
-
865
1260
  /**
866
1261
  * Hooks into the ngModel selector and triggers an asynchronous validation for a form model
867
1262
  * It will use a vest suite behind the scenes
@@ -869,12 +1264,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
869
1264
  class FormModelDirective {
870
1265
  constructor() {
871
1266
  this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
872
- this.formDirective = inject(FormDirective);
1267
+ /**
1268
+ * Reference to the form that needs to be validated
1269
+ * Injected optionally so that using ngModel outside of an ngxVestForm
1270
+ * does not crash the application. In that case, validation becomes a no-op.
1271
+ */
1272
+ this.formDirective = inject(FormDirective, {
1273
+ optional: true,
1274
+ });
873
1275
  }
874
1276
  validate(control) {
875
- const { ngForm, suite, formValue } = this.formDirective;
1277
+ // Null check for control
1278
+ if (!control) {
1279
+ if (isDevMode()) {
1280
+ console.debug('[ngx-vest-forms] Validate called with null control in FormModelDirective.');
1281
+ }
1282
+ return of(null);
1283
+ }
1284
+ // Null check for form context
1285
+ const context = this.formDirective;
1286
+ if (!context) {
1287
+ if (isDevMode()) {
1288
+ console.debug('[ngx-vest-forms] ngModel used outside of ngxVestForm; skipping validation.');
1289
+ }
1290
+ return of(null);
1291
+ }
1292
+ const { ngForm } = context;
876
1293
  const field = getFormControlField(ngForm.control, control);
877
- return this.formDirective.createAsyncValidator(field, this.validationOptions())(control.getRawValue());
1294
+ if (!field) {
1295
+ if (isDevMode()) {
1296
+ console.debug('[ngx-vest-forms] Could not determine field name for validation in FormModelDirective (skipping).');
1297
+ }
1298
+ return of(null);
1299
+ }
1300
+ const asyncValidator = context.createAsyncValidator(field, this.validationOptions());
1301
+ // Pass the control value to the validator
1302
+ const validationResult = asyncValidator(control.value);
1303
+ if (validationResult instanceof Observable) {
1304
+ return validationResult;
1305
+ }
1306
+ else if (validationResult instanceof Promise) {
1307
+ if (isDevMode()) {
1308
+ console.debug('[ngx-vest-forms] Async validator returned a Promise. Converting to Observable.');
1309
+ }
1310
+ return from(validationResult);
1311
+ }
1312
+ else {
1313
+ if (isDevMode()) {
1314
+ console.error('[ngx-vest-forms] Async validator returned an unexpected type:', validationResult);
1315
+ }
1316
+ return of(null);
1317
+ }
878
1318
  }
879
1319
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
880
1320
  static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.1", type: FormModelDirective, isStandalone: true, selector: "[ngModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
@@ -889,7 +1329,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
889
1329
  type: Directive,
890
1330
  args: [{
891
1331
  selector: '[ngModel]',
892
- standalone: true,
893
1332
  providers: [
894
1333
  {
895
1334
  provide: NG_ASYNC_VALIDATORS,
@@ -907,12 +1346,49 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
907
1346
  class FormModelGroupDirective {
908
1347
  constructor() {
909
1348
  this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
910
- this.formDirective = inject(FormDirective);
1349
+ this.formDirective = inject(FormDirective, { optional: true });
911
1350
  }
912
1351
  validate(control) {
913
- const { ngForm } = this.formDirective;
1352
+ // Null check for control
1353
+ if (!control) {
1354
+ if (isDevMode()) {
1355
+ console.debug('[ngx-vest-forms] Validate called with null control in FormModelGroupDirective.');
1356
+ }
1357
+ return of(null);
1358
+ }
1359
+ // Null check for form context
1360
+ const context = this.formDirective;
1361
+ if (!context) {
1362
+ if (isDevMode()) {
1363
+ console.debug('[ngx-vest-forms] ngModelGroup used outside of ngxVestForm; skipping validation.');
1364
+ }
1365
+ return of(null);
1366
+ }
1367
+ const { ngForm } = context;
914
1368
  const field = getFormGroupField(ngForm.control, control);
915
- return this.formDirective.createAsyncValidator(field, this.validationOptions())(control.value);
1369
+ if (!field) {
1370
+ if (isDevMode()) {
1371
+ console.debug('[ngx-vest-forms] Could not determine field name for validation in FormModelGroupDirective (skipping).');
1372
+ }
1373
+ return of(null);
1374
+ }
1375
+ const asyncValidator = context.createAsyncValidator(field, this.validationOptions());
1376
+ const validationResult = asyncValidator(control.value);
1377
+ if (validationResult instanceof Observable) {
1378
+ return validationResult;
1379
+ }
1380
+ else if (validationResult instanceof Promise) {
1381
+ if (isDevMode()) {
1382
+ console.debug('[ngx-vest-forms] Async validator returned a Promise. Converting to Observable.');
1383
+ }
1384
+ return from(validationResult);
1385
+ }
1386
+ else {
1387
+ if (isDevMode()) {
1388
+ console.error('[ngx-vest-forms] Async validator returned an unexpected type:', validationResult);
1389
+ }
1390
+ return of(null);
1391
+ }
916
1392
  }
917
1393
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
918
1394
  static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.1", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
@@ -927,7 +1403,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImpor
927
1403
  type: Directive,
928
1404
  args: [{
929
1405
  selector: '[ngModelGroup]',
930
- standalone: true,
931
1406
  providers: [
932
1407
  {
933
1408
  provide: NG_ASYNC_VALIDATORS,
@@ -1207,13 +1682,9 @@ function keepFieldsWhen(currentState, conditions) {
1207
1682
  return result;
1208
1683
  }
1209
1684
 
1210
- /*
1211
- * Public API Surface of ngx-vest-forms
1212
- */
1213
-
1214
1685
  /**
1215
1686
  * Generated bundle index. Do not edit.
1216
1687
  */
1217
1688
 
1218
- export { ControlWrapperComponent, FormDirective, FormModelDirective, FormModelGroupDirective, ROOT_FORM, ShapeMismatchError, VALIDATION_CONFIG_DEBOUNCE_TIME, ValidateRootFormDirective, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeValuesAndRawValues, set, shallowEqual, validateShape, vestForms, vestFormsViewProviders };
1689
+ export { ControlWrapperComponent, FormControlStateDirective, FormDirective, FormErrorDisplayDirective, FormModelDirective, FormModelGroupDirective, ROOT_FORM, ShapeMismatchError, VALIDATION_CONFIG_DEBOUNCE_TIME, ValidateRootFormDirective, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeValuesAndRawValues, set, shallowEqual, validateShape, vestForms, vestFormsViewProviders };
1219
1690
  //# sourceMappingURL=ngx-vest-forms.mjs.map