ngx-vest-forms 2.1.0 → 2.3.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,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, contentChild, inject, Injector, computed, signal, effect, afterEveryRender, Directive, input, ElementRef, DestroyRef, ChangeDetectionStrategy, Component, isDevMode, ChangeDetectorRef, linkedSignal, untracked, booleanAttribute, Optional } from '@angular/core';
3
- import { NgModel, NgModelGroup, NgForm, FormGroup, FormArray, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
2
+ import { InjectionToken, isDevMode, inject, DestroyRef, ChangeDetectorRef, signal, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender, ElementRef, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
3
+ import { FormGroup, FormArray, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, NgModel, NgModelGroup, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
4
4
  import { toSignal, outputFromObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
5
- import { merge, of, startWith, map, filter, distinctUntilChanged, timer, switchMap, Observable, catchError, take, EMPTY, debounceTime, tap, race, from } from 'rxjs';
5
+ import { startWith, filter, map, distinctUntilChanged, merge, switchMap, take, of, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
6
6
 
7
7
  /**
8
8
  * @deprecated Use NGX_ERROR_DISPLAY_MODE_TOKEN instead
@@ -13,1016 +13,318 @@ const SC_ERROR_DISPLAY_MODE_TOKEN = new InjectionToken('SC_ERROR_DISPLAY_MODE_TO
13
13
  });
14
14
  /**
15
15
  * Injection token for configuring the default error display mode.
16
- * Values: 'on-blur' | 'on-submit' | 'on-blur-or-submit' (default)
16
+ * Values:
17
+ * - 'on-blur': Show errors after field is touched/blurred
18
+ * - 'on-submit': Show errors after form submission
19
+ * - 'on-blur-or-submit': Show errors after blur or form submission (default)
20
+ * - 'on-dirty': Show errors as soon as the field value changes
21
+ * - 'always': Show errors immediately, even on pristine fields
17
22
  */
18
23
  const NGX_ERROR_DISPLAY_MODE_TOKEN = new InjectionToken('NGX_ERROR_DISPLAY_MODE_TOKEN', {
19
24
  providedIn: 'root',
20
25
  factory: () => 'on-blur-or-submit',
21
26
  });
22
-
23
- function getInitialFormControlState() {
24
- return {
25
- status: 'INVALID',
26
- isValid: false,
27
- isInvalid: true,
28
- isPending: false,
29
- isDisabled: false,
30
- isTouched: false,
31
- isDirty: false,
32
- isPristine: true,
33
- errors: null,
34
- };
35
- }
36
- class FormControlStateDirective {
37
- #hostNgModel;
38
- #hostNgModelGroup;
39
- #injector;
40
- /**
41
- * Computed signal for the active control (NgModel or NgModelGroup)
42
- */
43
- #activeControl;
44
- /**
45
- * Internal signal for robust touched/dirty tracking (syncs after every render)
46
- */
47
- #interactionState;
48
- /**
49
- * Track whether this control has been validated at least once.
50
- * This is separate from touched - a field can be validated via validationConfig
51
- * without being touched by the user. This flag enables showing errors for
52
- * validationConfig-triggered validations even before user interaction.
53
- */
54
- #hasBeenValidated;
55
- /**
56
- * Track the previous status to detect actual status changes (not just status emissions).
57
- * This helps distinguish between initial control creation and actual re-validation.
58
- */
59
- #previousStatus;
60
- /**
61
- * Internal signal for control state (updated reactively)
62
- */
63
- #controlStateSignal;
64
- constructor() {
65
- this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
66
- this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : []));
67
- this.#hostNgModel = inject(NgModel, {
68
- self: true,
69
- optional: true,
70
- });
71
- this.#hostNgModelGroup = inject(NgModelGroup, {
72
- self: true,
73
- optional: true,
74
- });
75
- this.#injector = inject(Injector);
76
- /**
77
- * Computed signal for the active control (NgModel or NgModelGroup)
78
- */
79
- this.#activeControl = computed(() => {
80
- return (this.#hostNgModel ||
81
- this.#hostNgModelGroup ||
82
- this.contentNgModel() ||
83
- this.contentNgModelGroup() ||
84
- null);
85
- }, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
86
- /**
87
- * Internal signal for robust touched/dirty tracking (syncs after every render)
88
- */
89
- this.#interactionState = signal({
90
- isTouched: false,
91
- isDirty: false,
92
- }, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
93
- /**
94
- * Track whether this control has been validated at least once.
95
- * This is separate from touched - a field can be validated via validationConfig
96
- * without being touched by the user. This flag enables showing errors for
97
- * validationConfig-triggered validations even before user interaction.
98
- */
99
- this.#hasBeenValidated = signal(false, ...(ngDevMode ? [{ debugName: "#hasBeenValidated" }] : []));
100
- /**
101
- * Track the previous status to detect actual status changes (not just status emissions).
102
- * This helps distinguish between initial control creation and actual re-validation.
103
- */
104
- this.#previousStatus = undefined;
105
- /**
106
- * Internal signal for control state (updated reactively)
107
- */
108
- this.#controlStateSignal = signal(getInitialFormControlState(), ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : []));
109
- /**
110
- * Main control state computed signal (merges robust touched/dirty)
111
- */
112
- this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
113
- /**
114
- * Extracts error messages from Angular/Vest errors (recursively flattens)
115
- */
116
- this.errorMessages = computed(() => {
117
- const state = this.controlState();
118
- if (!state?.errors)
119
- return [];
120
- // Vest errors are stored in the 'errors' property as an array
121
- const vestErrors = state.errors['errors'];
122
- if (Array.isArray(vestErrors)) {
123
- return vestErrors;
124
- }
125
- // Fallback to flattened Angular error keys
126
- return this.#flattenAngularErrors(state.errors);
127
- }, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
128
- /**
129
- * ADVANCED: updateOn strategy (change/blur/submit) if available
130
- */
131
- this.updateOn = computed(() => {
132
- const ngModel = this.contentNgModel() || this.#hostNgModel;
133
- // Angular's NgModel.options?.updateOn
134
- return ngModel?.options?.updateOn ?? 'change';
135
- }, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
136
- /**
137
- * ADVANCED: Composite/derived signals for advanced error display logic
138
- */
139
- this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
140
- this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
141
- this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
142
- /**
143
- * Extracts warning messages from Vest validation results (robust)
144
- */
145
- this.warningMessages = computed(() => {
146
- const state = this.controlState();
147
- if (!state?.errors)
148
- return [];
149
- const warnings = state.errors['warnings'];
150
- if (Array.isArray(warnings)) {
151
- return warnings;
152
- }
153
- // Optionally, flatten nested warnings if needed in future
154
- return [];
155
- }, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
156
- /**
157
- * Whether async validation is in progress
158
- */
159
- this.hasPendingValidation = computed(() => !!this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
160
- /**
161
- * Convenience signals for common state checks
162
- */
163
- this.isValid = computed(() => this.controlState().isValid || false, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
164
- this.isInvalid = computed(() => this.controlState().isInvalid || false, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
165
- this.isPending = computed(() => this.controlState().isPending || false, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
166
- this.isTouched = computed(() => this.controlState().isTouched || false, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
167
- this.isDirty = computed(() => this.controlState().isDirty || false, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
168
- this.isPristine = computed(() => this.controlState().isPristine || false, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
169
- this.isDisabled = computed(() => this.controlState().isDisabled || false, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
170
- this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : []));
171
- /**
172
- * Whether this control has been validated at least once.
173
- * True after the first validation completes, even if the user hasn't touched the field.
174
- * This enables showing errors for validationConfig-triggered validations.
175
- */
176
- this.hasBeenValidated = computed(() => this.#hasBeenValidated(), ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : []));
177
- // Update control state reactively
178
- effect(() => {
179
- const control = this.#activeControl();
180
- const interaction = this.#interactionState();
181
- if (!control) {
182
- this.#controlStateSignal.set(getInitialFormControlState());
183
- return;
184
- }
185
- // Listen to control changes
186
- const sub = control.control?.statusChanges?.subscribe(() => {
187
- const { status, valid, invalid, pending, disabled, pristine, errors, touched, dirty, } = control;
188
- // Mark as validated when any of the following conditions are met:
189
- // 1. The control has been touched (user blurred the field).
190
- // 2. The control's status has actually changed (not the first status emission),
191
- // AND the new status is not 'PENDING' (validation completed),
192
- // AND the control has been interacted with (dirty).
193
- //
194
- // This ensures hasBeenValidated is true for:
195
- // - User blur events (touched becomes true)
196
- // - User-triggered validations (dirty)
197
- // - ValidationConfig-triggered validations that result in the control becoming touched
198
- // But NOT for initial page load validations.
199
- //
200
- // Accessibility: The logic is structured for clarity and maintainability.
201
- // IMPORTANT: Read touched/dirty directly from control, not from signal,
202
- // to avoid race conditions with afterEveryRender sync.
203
- if (touched || // Control was blurred (most common case)
204
- (this.#previousStatus !== undefined && // Not the first status emission
205
- this.#previousStatus !== status && // Status actually changed
206
- status &&
207
- status !== 'PENDING' &&
208
- dirty) // Or control value changed (typed)
209
- ) {
210
- this.#hasBeenValidated.set(true);
211
- }
212
- // Track current status for next iteration
213
- this.#previousStatus = status;
214
- this.#controlStateSignal.set({
215
- status,
216
- isValid: valid,
217
- isInvalid: invalid,
218
- isPending: pending,
219
- isDisabled: disabled,
220
- isTouched: interaction.isTouched,
221
- isDirty: interaction.isDirty,
222
- isPristine: pristine,
223
- errors,
224
- });
225
- });
226
- // Initial update
227
- const { status, valid, invalid, pending, disabled, pristine, errors } = control;
228
- // Set initial previous status
229
- this.#previousStatus = status;
230
- this.#controlStateSignal.set({
231
- status,
232
- isValid: valid,
233
- isInvalid: invalid,
234
- isPending: pending,
235
- isDisabled: disabled,
236
- isTouched: interaction.isTouched,
237
- isDirty: interaction.isDirty,
238
- isPristine: pristine,
239
- errors,
240
- });
241
- return () => sub?.unsubscribe();
242
- });
243
- // Robustly sync touched/dirty after every render (Angular 18+ best practice)
244
- afterEveryRender(() => {
245
- const control = this.#activeControl();
246
- if (control) {
247
- const current = this.#interactionState();
248
- const newTouched = control.touched ?? false;
249
- const newDirty = control.dirty ?? false;
250
- if (newTouched !== current.isTouched ||
251
- newDirty !== current.isDirty) {
252
- this.#interactionState.set({
253
- isTouched: newTouched,
254
- isDirty: newDirty,
255
- });
256
- // Mark as validated when control becomes touched (e.g., user blurred the field)
257
- // This handles the case where blur doesn't trigger statusChanges (field already invalid)
258
- if (newTouched && !current.isTouched) {
259
- this.#hasBeenValidated.set(true);
260
- }
261
- }
262
- }
263
- }, { injector: this.#injector });
264
- }
265
- /**
266
- * Recursively flattens Angular error objects into an array of error keys.
267
- */
268
- #flattenAngularErrors(errors) {
269
- const result = [];
270
- for (const key of Object.keys(errors)) {
271
- const value = errors[key];
272
- if (typeof value === 'object' && value !== null) {
273
- result.push(...this.#flattenAngularErrors(value));
274
- }
275
- else {
276
- result.push(key);
277
- }
278
- }
279
- return result;
280
- }
281
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
282
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.0", type: FormControlStateDirective, isStandalone: true, selector: "[formControlState], [ngxControlState]", queries: [{ propertyName: "contentNgModel", first: true, predicate: NgModel, descendants: true, isSignal: true }, { propertyName: "contentNgModelGroup", first: true, predicate: NgModelGroup, descendants: true, isSignal: true }], exportAs: ["formControlState", "ngxControlState"], ngImport: i0 }); }
283
- }
284
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormControlStateDirective, decorators: [{
285
- type: Directive,
286
- args: [{
287
- selector: '[formControlState], [ngxControlState]',
288
- exportAs: 'formControlState, ngxControlState',
289
- }]
290
- }], ctorParameters: () => [], propDecorators: { contentNgModel: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModel), { isSignal: true }] }], contentNgModelGroup: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModelGroup), { isSignal: true }] }] } });
291
-
292
- const SC_ERROR_DISPLAY_MODE_DEFAULT = 'on-blur-or-submit';
293
- class FormErrorDisplayDirective {
294
- #formControlState;
295
- // Optionally inject NgForm for form submission tracking
296
- #ngForm;
297
- constructor() {
298
- this.#formControlState = inject(FormControlStateDirective);
299
- // Optionally inject NgForm for form submission tracking
300
- this.#ngForm = inject(NgForm, { optional: true });
301
- /**
302
- * Input signal for error display mode.
303
- * Works seamlessly with hostDirectives in Angular 19+.
304
- */
305
- this.errorDisplayMode = input(inject(NGX_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
306
- inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
307
- SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
308
- // Expose state signals from FormControlStateDirective
309
- this.controlState = this.#formControlState.controlState;
310
- this.errorMessages = this.#formControlState.errorMessages;
311
- this.warningMessages = this.#formControlState.warningMessages;
312
- this.hasPendingValidation = this.#formControlState.hasPendingValidation;
313
- this.isTouched = this.#formControlState.isTouched;
314
- this.isDirty = this.#formControlState.isDirty;
315
- this.isValid = this.#formControlState.isValid;
316
- this.isInvalid = this.#formControlState.isInvalid;
317
- this.hasBeenValidated = this.#formControlState.hasBeenValidated;
318
- /**
319
- * Expose updateOn and formSubmitted as public signals for advanced consumers.
320
- * updateOn: The ngModelOptions.updateOn value for the control (change/blur/submit)
321
- * formSubmitted: true after the form is submitted (if NgForm is present)
322
- */
323
- this.updateOn = this.#formControlState.updateOn;
324
- /**
325
- * Signal that tracks NgForm.submitted state reactively.
326
- *
327
- * Uses toSignal() to convert ngSubmit events + statusChanges into a reactive signal.
328
- * - ngSubmit: fires when form is submitted (sets NgForm.submitted = true)
329
- * - statusChanges: fires after resetForm() (which sets NgForm.submitted = false)
330
- *
331
- * This ensures proper sync with both submit and reset operations.
332
- */
333
- this.formSubmitted = this.#ngForm
334
- ? toSignal(merge(this.#ngForm.ngSubmit, this.#ngForm.statusChanges ?? of()).pipe(startWith(null), map(() => this.#ngForm?.submitted ?? false)), { initialValue: this.#ngForm.submitted })
335
- : toSignal(of(false), { initialValue: false });
336
- /**
337
- * Determines if errors should be shown based on the specified display mode
338
- * and the control's state (touched/submitted/validated).
339
- *
340
- * Note: We check both hasErrors (extracted error messages) AND isInvalid (Angular's validation state)
341
- * because in some cases (like conditional validations via validationConfig), the control is marked
342
- * as invalid by Angular before error messages are extracted from Vest. This ensures aria-invalid
343
- * is set correctly even during the validation propagation delay.
344
- *
345
- * For validationConfig-triggered validations: A field can be validated without being touched
346
- * (e.g., confirmPassword validated when password changes). We check hasBeenValidated to show
347
- * errors in these scenarios, providing better UX and proper ARIA attributes.
348
- */
349
- this.shouldShowErrors = computed(() => {
350
- const mode = this.errorDisplayMode();
351
- const isTouched = this.isTouched();
352
- const isInvalid = this.isInvalid();
353
- const hasErrors = this.errorMessages().length > 0;
354
- const updateOn = this.updateOn();
355
- const formSubmitted = this.formSubmitted();
356
- // Consider errors present if either we have error messages OR the control is invalid
357
- // This handles the race condition where Angular marks control invalid before Vest errors propagate
358
- const hasErrorState = hasErrors || isInvalid;
359
- // Always only show errors after submit if updateOn is 'submit'
360
- if (updateOn === 'submit') {
361
- return !!(formSubmitted && hasErrorState);
362
- }
363
- // on-blur: show errors after blur (touch)
364
- if (mode === 'on-blur') {
365
- return !!(isTouched && hasErrorState);
366
- }
367
- // on-submit: show errors after submit
368
- if (mode === 'on-submit') {
369
- return !!(formSubmitted && hasErrorState);
370
- }
371
- // on-blur-or-submit: show errors after blur (touch) OR submit
372
- return !!((isTouched || formSubmitted) && hasErrorState);
373
- }, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
374
- /**
375
- * Errors to display (filtered for pending state)
376
- */
377
- this.errors = computed(() => {
378
- if (this.hasPendingValidation())
379
- return [];
380
- return this.errorMessages();
381
- }, ...(ngDevMode ? [{ debugName: "errors" }] : []));
382
- /**
383
- * Warnings to display (filtered for pending state)
384
- */
385
- this.warnings = computed(() => {
386
- if (this.hasPendingValidation())
387
- return [];
388
- return this.warningMessages();
389
- }, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
390
- /**
391
- * Whether the control is currently being validated (pending)
392
- * Excludes pristine+untouched controls to prevent "Validating..." on initial load
393
- */
394
- this.isPending = computed(() => {
395
- // Don't show pending state for pristine untouched controls
396
- // This prevents "Validating..." message appearing on initial page load
397
- const state = this.#formControlState.controlState();
398
- if (state.isPristine && !state.isTouched) {
399
- return false;
400
- }
401
- return this.hasPendingValidation();
402
- }, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
403
- // Warn about problematic combinations of updateOn and errorDisplayMode
404
- effect(() => {
405
- const mode = this.errorDisplayMode();
406
- const updateOn = this.updateOn();
407
- if (updateOn === 'submit' && mode === 'on-blur') {
408
- 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.');
409
- }
410
- });
411
- }
412
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
413
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: FormErrorDisplayDirective, isStandalone: true, selector: "[formErrorDisplay], [ngxErrorDisplay]", inputs: { errorDisplayMode: { classPropertyName: "errorDisplayMode", publicName: "errorDisplayMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorDisplay", "ngxErrorDisplay"], hostDirectives: [{ directive: FormControlStateDirective }], ngImport: i0 }); }
414
- }
415
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
416
- type: Directive,
417
- args: [{
418
- selector: '[formErrorDisplay], [ngxErrorDisplay]',
419
- exportAs: 'formErrorDisplay, ngxErrorDisplay',
420
- hostDirectives: [FormControlStateDirective],
421
- }]
422
- }], ctorParameters: () => [], propDecorators: { errorDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorDisplayMode", required: false }] }] } });
423
-
424
- /**
425
- * Creates a debounced pending state signal that prevents flashing validation messages.
426
- *
427
- * This utility helps implement a better UX for async validations by:
428
- * 1. Delaying the display of "Validating..." messages until validation takes longer than `showAfter`ms (default: 200ms)
429
- * 2. Keeping the message visible for at least `minimumDisplay`ms (default: 500ms) once shown to prevent flickering
430
- *
431
- * @example
432
- * ```typescript
433
- * @Component({
434
- * template: `
435
- * @if (showPendingMessage()) {
436
- * <div role="status" aria-live="polite">Validating…</div>
437
- * }
438
- * `
439
- * })
440
- * export class CustomControlWrapperComponent {
441
- * protected readonly errorDisplay = inject(FormErrorDisplayDirective, { self: true });
442
- *
443
- * // Create debounced pending state
444
- * private readonly pendingState = createDebouncedPendingState(
445
- * this.errorDisplay.isPending,
446
- * { showAfter: 200, minimumDisplay: 500 }
447
- * );
448
- *
449
- * protected readonly showPendingMessage = this.pendingState.showPendingMessage;
450
- * }
451
- * ```
452
- *
453
- * @param isPending - Signal indicating whether async validation is currently pending
454
- * @param options - Configuration options for debouncing behavior
455
- * @returns Object containing the debounced showPendingMessage signal and cleanup function
456
- */
457
- function createDebouncedPendingState(isPending, options = {}) {
458
- const { showAfter = 200, minimumDisplay = 500 } = options;
459
- // Create writable signal for debounced state
460
- const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : []));
461
- // Track timeouts
462
- let pendingTimeout = null;
463
- let minimumDisplayTimeout = null;
464
- // Cleanup function
465
- const cleanup = () => {
466
- if (pendingTimeout) {
467
- clearTimeout(pendingTimeout);
468
- pendingTimeout = null;
469
- }
470
- if (minimumDisplayTimeout) {
471
- clearTimeout(minimumDisplayTimeout);
472
- minimumDisplayTimeout = null;
473
- }
474
- };
475
- // Effect to manage debounced pending message display
476
- effect((onCleanup) => {
477
- const pending = isPending();
478
- if (pending) {
479
- // Clear any existing minimum display timeout
480
- if (minimumDisplayTimeout) {
481
- clearTimeout(minimumDisplayTimeout);
482
- minimumDisplayTimeout = null;
483
- }
484
- // Start delay timer before showing pending message
485
- pendingTimeout = setTimeout(() => {
486
- showPendingMessageSignal.set(true);
487
- pendingTimeout = null;
488
- }, showAfter);
489
- onCleanup(() => {
490
- if (pendingTimeout) {
491
- clearTimeout(pendingTimeout);
492
- pendingTimeout = null;
493
- }
494
- });
495
- }
496
- else {
497
- // Validation completed
498
- if (pendingTimeout) {
499
- // Validation completed before delay - don't show message
500
- clearTimeout(pendingTimeout);
501
- pendingTimeout = null;
502
- }
503
- else if (showPendingMessageSignal()) {
504
- // Message was shown - keep it visible for minimum duration
505
- minimumDisplayTimeout = setTimeout(() => {
506
- showPendingMessageSignal.set(false);
507
- minimumDisplayTimeout = null;
508
- }, minimumDisplay);
509
- onCleanup(() => {
510
- if (minimumDisplayTimeout) {
511
- clearTimeout(minimumDisplayTimeout);
512
- minimumDisplayTimeout = null;
513
- }
514
- });
515
- }
516
- }
517
- });
518
- return {
519
- showPendingMessage: showPendingMessageSignal.asReadonly(),
520
- cleanup,
521
- };
522
- }
523
-
524
- // Counter for unique IDs
525
- let nextUniqueId$2 = 0;
526
- /**
527
- * Accessible form control wrapper with WCAG 2.2 AA compliance.
528
- *
529
- * Wrap form fields to automatically display validation errors, warnings, and pending states
530
- * with proper accessibility attributes.
531
- *
532
- * @usageNotes
533
- *
534
- * ### Basic Usage
535
- * ```html
536
- * <ngx-control-wrapper>
537
- * <label for="email">Email</label>
538
- * <input id="email" name="email" [ngModel]="formValue().email" />
539
- * </ngx-control-wrapper>
540
- * ```
541
- *
542
- * ### Error Display Modes
543
- * Control when errors appear using the `errorDisplayMode` input:
544
- * - `'on-blur-or-submit'` (default): Show errors after blur OR form submit
545
- * - `'on-blur'`: Show errors only after blur
546
- * - `'on-submit'`: Show errors only after form submit
547
- *
548
- * ```html
549
- * <ngx-control-wrapper [errorDisplayMode]="'on-submit'">
550
- * <input name="email" [ngModel]="formValue().email" />
551
- * </ngx-control-wrapper>
552
- * ```
553
- *
554
- * ### Accessibility Features (Automatic)
555
- * - Unique IDs for error/warning/pending regions
556
- * - `aria-describedby` linking errors to form controls
557
- * - `aria-invalid="true"` when errors are shown
558
- * - `role="status"` with `aria-live="polite"` for non-interruptive announcements
559
- * - Debounced pending state to prevent flashing for quick validations
560
- *
561
- * @see {@link FormErrorDisplayDirective} for custom wrapper implementation
562
- *
563
- * Blocking Errors (form-level):
564
- * - For post-submit validation errors that block submission, implement a separate
565
- * form-level error summary with role="alert" and aria-live="assertive"
566
- * - Example:
567
- * ```html
568
- * <!-- Keep in DOM; update text content on submit -->
569
- * <div id="form-errors" role="alert" aria-live="assertive" aria-atomic="true"></div>
570
- * ```
571
- * - This provides immediate, reliable announcements for blocking errors while keeping
572
- * inline field errors non-disruptive. Follows WCAG ARIA19/ARIA22 guidance.
573
- *
574
- * Error & Warning Display Behavior:
575
- * - 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).
576
- * - Possible values: 'on-blur' | 'on-submit' | 'on-blur-or-submit' (default: 'on-blur-or-submit')
577
- *
578
- * Example (per instance):
579
- * <div ngxControlWrapper>
580
- * <label>
581
- * <span>First name</span>
582
- * <input type="text" name="firstName" [ngModel]="formValue().firstName" />
583
- * </label>
584
- * </div>
585
- * /// To customize errorDisplayMode for this instance, use the errorDisplayMode input.
586
- *
587
- * Example (with warnings and pending):
588
- * <ngx-control-wrapper>
589
- * <input name="username" ngModel />
590
- * </ngx-control-wrapper>
591
- * /// If async validation is running for >200ms, a spinner and 'Validating…' will be shown.
592
- * /// Once shown, the validation message stays visible for minimum 500ms to prevent flashing.
593
- * /// If Vest warnings are present, they will be shown below errors.
594
- *
595
- * Example (global config):
596
- * import { provide } from '@angular/core';
597
- * import { SC_ERROR_DISPLAY_MODE_TOKEN } from 'ngx-vest-forms';
598
- * @Component({
599
- * providers: [
600
- * provide(SC_ERROR_DISPLAY_MODE_TOKEN, { useValue: 'submit' })
601
- * ]
602
- * })
603
- * export class MyComponent {}
604
- *
605
- * Best Practices:
606
- * - Use for every input or group in your forms.
607
- * - Do not manually display errors for individual fields; rely on this wrapper.
608
- * - Validate with tools like Accessibility Insights and real screen reader testing.
609
- *
610
- * @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA19 - ARIA19: Using ARIA role=alert
611
- * @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - ARIA22: Using role=status
612
- */
613
- class ControlWrapperComponent {
614
- mergeAriaDescribedBy(existing, wrapperActiveIds) {
615
- const existingTokens = (existing ?? '')
616
- .split(/\s+/)
617
- .map((t) => t.trim())
618
- .filter(Boolean);
619
- // Remove any previous wrapper-owned IDs from the existing list.
620
- const existingWithoutWrapper = existingTokens.filter((t) => !this.wrapperOwnedDescribedByIds.includes(t));
621
- // Append current wrapper IDs, preserving existing order and uniqueness.
622
- const merged = [...existingWithoutWrapper];
623
- for (const id of wrapperActiveIds) {
624
- if (!merged.includes(id)) {
625
- merged.push(id);
626
- }
627
- }
628
- return merged.length > 0 ? merged.join(' ') : null;
629
- }
630
- constructor() {
631
- this.errorDisplay = inject(FormErrorDisplayDirective, {
632
- self: true,
633
- });
634
- this.elementRef = inject(ElementRef);
635
- this.destroyRef = inject(DestroyRef);
636
- /**
637
- * Controls how this wrapper applies ARIA attributes to descendant controls.
638
- *
639
- * - `all-controls` (default, backwards compatible): apply `aria-describedby` / `aria-invalid`
640
- * to all `input/select/textarea` elements inside the wrapper.
641
- * - `single-control`: apply ARIA attributes only when exactly one control is found.
642
- * (Useful for wrappers that sometimes contain helper buttons/controls.)
643
- * - `none`: do not mutate descendant controls at all (group-safe mode).
644
- *
645
- * Notes:
646
- * - Use `none` when wrapping a container (e.g. `NgModelGroup`) to avoid stamping ARIA
647
- * across multiple child controls.
648
- * - This does not affect whether messages render; it only affects ARIA wiring.
649
- */
650
- this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
651
- // Generate unique IDs for ARIA associations
652
- this.uniqueId = `ngx-control-wrapper-${nextUniqueId$2++}`;
653
- this.errorId = `${this.uniqueId}-error`;
654
- this.warningId = `${this.uniqueId}-warning`;
655
- this.pendingId = `${this.uniqueId}-pending`;
656
- // Track form controls found in the wrapper
657
- this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
658
- // Signals when content is initialized so effects can safely touch the DOM.
659
- this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
660
- // MutationObserver to detect dynamically added/removed controls
661
- this.mutationObserver = null;
662
- /**
663
- * Debounced pending state to prevent flashing for quick async validations.
664
- * Uses createDebouncedPendingState utility with 500ms delay and 500ms minimum display.
665
- */
666
- this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
667
- this.showPendingMessage = this.pendingState.showPendingMessage;
668
- /**
669
- * Computed signal that builds aria-describedby string based on visible regions
670
- */
671
- this.ariaDescribedBy = computed(() => {
672
- const ids = [];
673
- if (this.errorDisplay.shouldShowErrors()) {
674
- ids.push(this.errorId);
675
- }
676
- if (this.errorDisplay.warnings().length > 0) {
677
- ids.push(this.warningId);
678
- }
679
- if (this.showPendingMessage()) {
680
- ids.push(this.pendingId);
681
- }
682
- return ids.length > 0 ? ids.join(' ') : null;
683
- }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
684
- /**
685
- * IDs managed by this wrapper when composing aria-describedby.
686
- *
687
- * We remove only these from the consumer-provided aria-describedby tokens and then
688
- * append the currently-relevant wrapper IDs. This prevents clobbering app-provided
689
- * hint/help text associations.
690
- */
691
- this.wrapperOwnedDescribedByIds = [
692
- this.errorId,
693
- this.warningId,
694
- this.pendingId,
695
- ];
696
- // Effect to update aria-describedby and aria-invalid on form controls
697
- effect(() => {
698
- if (!this.contentInitialized())
699
- return;
700
- const mode = this.ariaAssociationMode();
701
- if (mode === 'none') {
702
- return;
703
- }
704
- const describedBy = this.ariaDescribedBy();
705
- const wrapperActiveIds = describedBy
706
- ? describedBy.split(/\s+/).filter(Boolean)
707
- : [];
708
- const shouldShowErrors = this.errorDisplay.shouldShowErrors();
709
- const targets = (() => {
710
- const controls = this.formControls();
711
- if (mode === 'single-control') {
712
- return controls.length === 1 ? controls : [];
713
- }
714
- return controls;
715
- })();
716
- targets.forEach((control) => {
717
- // Update aria-describedby (merge, don't overwrite)
718
- const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), wrapperActiveIds);
719
- if (nextDescribedBy) {
720
- control.setAttribute('aria-describedby', nextDescribedBy);
721
- }
722
- else {
723
- control.removeAttribute('aria-describedby');
724
- }
725
- // Update aria-invalid
726
- if (shouldShowErrors) {
727
- control.setAttribute('aria-invalid', 'true');
728
- }
729
- else {
730
- control.removeAttribute('aria-invalid');
731
- }
732
- });
733
- });
734
- // Clean up MutationObserver when component is destroyed
735
- this.destroyRef.onDestroy(() => {
736
- this.mutationObserver?.disconnect();
737
- this.mutationObserver = null;
738
- });
739
- // Effect to enable/disable DOM observation based on ariaAssociationMode.
740
- // This keeps the wrapper cheap in group-safe mode.
741
- effect(() => {
742
- if (!this.contentInitialized())
743
- return;
744
- const mode = this.ariaAssociationMode();
745
- if (mode === 'none') {
746
- this.mutationObserver?.disconnect();
747
- this.mutationObserver = null;
748
- if (this.formControls().length > 0) {
749
- this.formControls.set([]);
750
- }
751
- return;
752
- }
753
- // Ensure controls list is up to date.
754
- this.updateFormControls();
755
- // Ensure MutationObserver is installed (dynamic @if/@for support).
756
- if (!this.mutationObserver) {
757
- this.mutationObserver = new MutationObserver(() => {
758
- this.updateFormControls();
759
- });
760
- this.mutationObserver.observe(this.elementRef.nativeElement, {
761
- childList: true,
762
- subtree: true,
763
- });
764
- }
765
- });
766
- }
767
- ngAfterContentInit() {
768
- this.contentInitialized.set(true);
769
- // ARIA wiring + observer setup is managed by effects so that the wrapper can
770
- // opt out (ariaAssociationMode="none").
771
- }
772
- ngOnDestroy() {
773
- this.mutationObserver?.disconnect();
774
- this.mutationObserver = null;
775
- }
776
- /**
777
- * Query and update the list of form controls within this wrapper.
778
- * Called on init and whenever the DOM structure changes.
779
- */
780
- updateFormControls() {
781
- const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
782
- this.formControls.set(Array.from(controls));
783
- }
784
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
785
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ControlWrapperComponent, isStandalone: true, selector: "ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-control-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-control-wrapper sc-control-wrapper" }, hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Live regions are kept stable in the DOM to improve announcement reliability.\n We update the inner content instead of relying on node mount/unmount.\n-->\n<div\n [id]=\"errorId\"\n class=\"text-sm text-red-600\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.shouldShowErrors()) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error.message || error }}</li>\n }\n </ul>\n }\n</div>\n\n<div\n [id]=\"warningId\"\n class=\"text-sm text-yellow-700\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<!-- Pending state is also stable; content appears only after the debounce delay -->\n<div\n [id]=\"pendingId\"\n class=\"absolute top-0 right-0 flex items-center gap-1 text-xs text-gray-500\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (showPendingMessage()) {\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n aria-hidden=\"true\"\n ></span>\n Validating\u2026\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
27
+ /**
28
+ * Injection token for configuring the default warning display mode.
29
+ * Values:
30
+ * - 'on-touch': Show warnings after field is touched/blurred
31
+ * - 'on-validated-or-touch': Show warnings after validation runs or field is touched (default)
32
+ * - 'on-dirty': Show warnings as soon as the field value changes
33
+ * - 'always': Show warnings immediately, even on pristine fields
34
+ */
35
+ const NGX_WARNING_DISPLAY_MODE_TOKEN = new InjectionToken('NGX_WARNING_DISPLAY_MODE_TOKEN', {
36
+ providedIn: 'root',
37
+ factory: () => 'on-validated-or-touch',
38
+ });
39
+
40
+ const NGX_VEST_FORMS_ERRORS = {
41
+ EXTRA_PROPERTY: {
42
+ code: 'NGX-001',
43
+ message: (path) => `Shape mismatch: Property '${path}' is present in the form value but not defined in the form shape.`,
44
+ },
45
+ TYPE_MISMATCH: {
46
+ code: 'NGX-002',
47
+ message: (path, expected, actual) => `Type mismatch at '${path}': Expected '${expected}' but got '${actual}'.`,
48
+ },
49
+ CONTROL_NOT_FOUND: {
50
+ code: 'NGX-003',
51
+ message: (path) => `Control not found: Could not find form control at path '${path}'. Check your [ngModel] name attributes.`,
52
+ },
53
+ };
54
+ function logWarning(error, ...args) {
55
+ console.warn(`[${error.code}] ${error.message(...args)}\nCheck your [formShape] input and the initial [formValue].`);
786
56
  }
787
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ControlWrapperComponent, decorators: [{
788
- type: Component,
789
- args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
790
- class: 'ngx-control-wrapper sc-control-wrapper',
791
- '[class.ngx-control-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
792
- '[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
793
- }, hostDirectives: [
794
- {
795
- directive: FormErrorDisplayDirective,
796
- inputs: ['errorDisplayMode'],
797
- },
798
- ], template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Live regions are kept stable in the DOM to improve announcement reliability.\n We update the inner content instead of relying on node mount/unmount.\n-->\n<div\n [id]=\"errorId\"\n class=\"text-sm text-red-600\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.shouldShowErrors()) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error.message || error }}</li>\n }\n </ul>\n }\n</div>\n\n<div\n [id]=\"warningId\"\n class=\"text-sm text-yellow-700\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<!-- Pending state is also stable; content appears only after the debounce delay -->\n<div\n [id]=\"pendingId\"\n class=\"absolute top-0 right-0 flex items-center gap-1 text-xs text-gray-500\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (showPendingMessage()) {\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n aria-hidden=\"true\"\n ></span>\n Validating\u2026\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"] }]
799
- }], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
800
57
 
801
- let nextUniqueId$1 = 0;
802
58
  /**
803
- * Group-safe wrapper for `NgModelGroup` containers.
59
+ * Injection token for configurable validation config debounce timing.
804
60
  *
805
- * This component renders group-level errors/warnings/pending UI, but intentionally
806
- * does **not** stamp `aria-describedby` / `aria-invalid` onto descendant controls.
61
+ * This token allows you to configure the debounce time for validation config
62
+ * dependencies at the application, route, or component level.
807
63
  *
808
- * Use this when you want a wrapper around a container that has multiple inputs.
809
- * For single inputs, prefer `<ngx-control-wrapper>`.
64
+ * @example
65
+ * ```typescript
66
+ * /// Global configuration
67
+ * export const appConfig: ApplicationConfig = {
68
+ * providers: [
69
+ * {
70
+ * provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
71
+ * useValue: 200
72
+ * }
73
+ * ]
74
+ * };
75
+ *
76
+ * /// Per-route configuration
77
+ * {
78
+ * path: 'checkout',
79
+ * component: CheckoutComponent,
80
+ * providers: [
81
+ * {
82
+ * provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
83
+ * useValue: 50
84
+ * }
85
+ * ]
86
+ * }
87
+ *
88
+ * /// Per-component override
89
+ * @Component({
90
+ * providers: [
91
+ * {
92
+ * provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
93
+ * useValue: 0 // for testing
94
+ * }
95
+ * ]
96
+ * })
97
+ * export class TestFormComponent {}
98
+ * ```
99
+ *
100
+ * @default 100ms - Maintains backward compatibility with existing behavior
810
101
  */
811
- class FormGroupWrapperComponent {
812
- constructor() {
813
- this.errorDisplay = inject(FormErrorDisplayDirective, {
814
- self: true,
815
- });
816
- /**
817
- * Controls the debounce behavior for the pending message.
818
- * Defaults are conservative to avoid flashing.
819
- */
820
- this.pendingDebounce = input({
821
- showAfter: 500,
822
- minimumDisplay: 500,
823
- }, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : []));
824
- this.uniqueId = `ngx-form-group-wrapper-${nextUniqueId$1++}`;
825
- this.errorId = `${this.uniqueId}-error`;
826
- this.warningId = `${this.uniqueId}-warning`;
827
- this.pendingId = `${this.uniqueId}-pending`;
828
- this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce());
829
- this.showPendingMessage = this.pendingState.showPendingMessage;
830
- /**
831
- * Helpful if consumers want to wire aria-describedby manually (e.g. fieldset/legend pattern).
832
- */
833
- this.describedByIds = computed(() => {
834
- const ids = [];
835
- if (this.errorDisplay.shouldShowErrors()) {
836
- ids.push(this.errorId);
837
- }
838
- if (this.errorDisplay.warnings().length > 0) {
839
- ids.push(this.warningId);
840
- }
841
- if (this.showPendingMessage()) {
842
- ids.push(this.pendingId);
843
- }
844
- return ids.length > 0 ? ids.join(' ') : null;
845
- }, ...(ngDevMode ? [{ debugName: "describedByIds" }] : []));
102
+ const NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN = new InjectionToken('NgxValidationConfigDebounceTime', {
103
+ providedIn: 'root',
104
+ factory: () => 100,
105
+ });
106
+
107
+ /**
108
+ * Type guard to check if a value is a primitive type.
109
+ *
110
+ * @param value - The value to check
111
+ * @returns true if the value is a primitive (string, number, boolean, null, undefined, symbol, bigint)
112
+ */
113
+ function isPrimitive$1(value) {
114
+ return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
115
+ }
116
+ /**
117
+ * @internal
118
+ * Internal utility for shallow equality checks.
119
+ *
120
+ * **Not intended for external use.** This function is used internally by the library
121
+ * for performance-critical operations. Consider using your own comparison logic or
122
+ * a library like lodash if you need shallow equality checks in your application.
123
+ *
124
+ * Optimized shallow equality check for objects.
125
+ *
126
+ * **Why this custom implementation is preferred:**
127
+ * - **Performance**: Direct property comparison is significantly faster than JSON.stringify
128
+ * - **Type Safety**: Handles null/undefined values correctly without serialization issues
129
+ * - **Accuracy**: Doesn't suffer from JSON.stringify limitations (undefined values, functions, symbols)
130
+ * - **Memory Efficient**: No temporary string creation or object serialization overhead
131
+ *
132
+ * **Use Cases:**
133
+ * - Form value change detection where only top-level properties matter
134
+ * - Quick object comparison in performance-critical code paths
135
+ * - Validation triggers where deep comparison is unnecessary
136
+ *
137
+ * **Performance Comparison:**
138
+ * ```typescript
139
+ * /// ❌ Slow: JSON.stringify approach
140
+ * JSON.stringify(obj1) === JSON.stringify(obj2)
141
+ *
142
+ * /// ✅ Fast: Direct property comparison
143
+ * shallowEqual(obj1, obj2)
144
+ * ```
145
+ *
146
+ * @param obj1 - First object to compare
147
+ * @param obj2 - Second object to compare
148
+ * @returns true if objects are shallowly equal (same keys and same values by reference)
149
+ */
150
+ function shallowEqual(obj1, obj2) {
151
+ if (obj1 === obj2) {
152
+ return true;
153
+ }
154
+ if (obj1 == null || obj2 == null) {
155
+ return obj1 === obj2;
156
+ }
157
+ if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
158
+ return obj1 === obj2;
159
+ }
160
+ const keys1 = Object.keys(obj1);
161
+ const keys2 = Object.keys(obj2);
162
+ if (keys1.length !== keys2.length) {
163
+ return false;
164
+ }
165
+ for (const key of keys1) {
166
+ if (!Object.hasOwn(obj2, key) ||
167
+ obj1[key] !==
168
+ obj2[key]) {
169
+ return false;
170
+ }
846
171
  }
847
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
848
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]", inputs: { pendingDebounce: { classPropertyName: "pendingDebounce", publicName: "pendingDebounce", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-form-group-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-form-group-wrapper sc-form-group-wrapper" }, exportAs: ["formGroupWrapper", "ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-form-group-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Keep regions stable in the DOM so IDs are always valid targets.\n This wrapper does NOT modify descendant controls.\n-->\n<div [id]=\"errorId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowErrors() && errorDisplay.errors().length > 0) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"warningId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"pendingId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (showPendingMessage()) {\n <span aria-hidden=\"true\">Validating\u2026</span>\n }\n</div>\n", styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
172
+ return true;
849
173
  }
850
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
851
- type: Component,
852
- args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]', exportAs: 'formGroupWrapper, ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
853
- class: 'ngx-form-group-wrapper sc-form-group-wrapper',
854
- '[class.ngx-form-group-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
855
- '[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
856
- }, hostDirectives: [
857
- {
858
- directive: FormErrorDisplayDirective,
859
- inputs: ['errorDisplayMode'],
860
- },
861
- ], template: "<div class=\"ngx-form-group-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Keep regions stable in the DOM so IDs are always valid targets.\n This wrapper does NOT modify descendant controls.\n-->\n<div [id]=\"errorId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowErrors() && errorDisplay.errors().length > 0) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"warningId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"pendingId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (showPendingMessage()) {\n <span aria-hidden=\"true\">Validating\u2026</span>\n }\n</div>\n", styles: [":host{display:block}\n"] }]
862
- }], propDecorators: { pendingDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingDebounce", required: false }] }] } });
863
-
864
- let nextUniqueId = 0;
865
174
  /**
866
- * Wires a control container to its error/warning/pending regions.
175
+ * @internal
176
+ * Internal utility for deep equality checks.
867
177
  *
868
- * This directive is intended for custom wrappers/components.
869
- * It composes `FormErrorDisplayDirective` (and thus `FormControlStateDirective`)
870
- * and applies `aria-invalid` / `aria-describedby` to descendant controls.
178
+ * **Not intended for external use.** This function is used internally by the library
179
+ * for form value comparison and change detection. Consider using your own comparison
180
+ * logic or a library like lodash if you need deep equality checks in your application.
871
181
  *
872
- * It does not render any UI; you can use the generated IDs to render messages.
182
+ * Fast deep equality check optimized for form values and Angular applications.
183
+ *
184
+ * **Why this custom implementation is preferred over alternatives:**
185
+ *
186
+ * **vs JSON.stringify():**
187
+ * - **10-100x faster**: Direct comparison without string serialization overhead
188
+ * - **Accurate**: Handles Date objects, RegExp, undefined values, and functions correctly
189
+ * - **Memory efficient**: No temporary string creation or garbage collection pressure
190
+ * - **Preserves semantics**: Maintains type information during comparison
191
+ *
192
+ * **vs structuredClone():**
193
+ * - **Wrong purpose**: structuredClone creates copies, not comparisons
194
+ * - **Performance**: Would require cloning both objects just to compare them
195
+ * - **Memory waste**: Creates unnecessary deep copies, doubling memory usage
196
+ * - **Still incomplete**: Even after cloning, you'd still need a comparison function
197
+ *
198
+ * **vs External libraries (lodash.isEqual, etc.):**
199
+ * - **Bundle size**: Zero dependencies, smaller application bundles
200
+ * - **Form-specific**: Optimized for common Angular form data patterns
201
+ * - **Type safety**: Full TypeScript integration with strict typing
202
+ * - **Performance**: Tailored algorithms for form value comparison use cases
203
+ *
204
+ * **Supported Data Types:**
205
+ * - Primitives (string, number, boolean, null, undefined, symbol, bigint)
206
+ * - Arrays (with recursive deep comparison)
207
+ * - Plain objects (with recursive deep comparison)
208
+ * - Date objects (by timestamp comparison)
209
+ * - RegExp objects (by source and flags comparison)
210
+ * - Set objects (by size and value membership)
211
+ * - Map objects (by size and key-value pairs)
212
+ *
213
+ * **Safety Features:**
214
+ * - **Circular reference protection**: MaxDepth parameter prevents infinite recursion
215
+ * - **Type coercion prevention**: Strict type checking before comparison
216
+ * - **Null safety**: Proper handling of null and undefined values
217
+ *
218
+ * **Performance Characteristics:**
219
+ * ```typescript
220
+ * /// Performance comparison on typical form objects:
221
+ * /// JSON.stringify: ~100ms for complex nested forms
222
+ * /// fastDeepEqual: ~1-5ms for the same objects
223
+ * ///
224
+ * /// Memory usage:
225
+ * /// JSON.stringify: Creates temporary strings (high GC pressure)
226
+ * /// fastDeepEqual: Zero allocations during comparison
227
+ * ```
228
+ *
229
+ * **Typical Usage in Forms:**
230
+ * ```typescript
231
+ * /// Detect when form values actually change
232
+ * distinctUntilChanged(fastDeepEqual)
233
+ *
234
+ * /// Prevent unnecessary re-renders
235
+ * if (!fastDeepEqual(oldFormValue, newFormValue)) {
236
+ * updateUI();
237
+ * }
238
+ * ```
239
+ *
240
+ * @param obj1 - First object to compare
241
+ * @param obj2 - Second object to compare
242
+ * @param maxDepth - Maximum recursion depth to prevent infinite loops (default: 10)
243
+ * @returns true if objects are deeply equal by value
873
244
  */
874
- class FormErrorControlDirective {
875
- mergeAriaDescribedBy(existing, activeIds) {
876
- const existingTokens = (existing ?? '')
877
- .split(/\s+/)
878
- .map((t) => t.trim())
879
- .filter(Boolean);
880
- const existingWithoutOwned = existingTokens.filter((t) => !this.ownedDescribedByIds.includes(t));
881
- const merged = [...existingWithoutOwned];
882
- for (const id of activeIds) {
883
- if (!merged.includes(id)) {
884
- merged.push(id);
245
+ function fastDeepEqual(obj1, obj2, maxDepth = 10) {
246
+ if (maxDepth <= 0) {
247
+ // Fallback to shallow comparison at max depth to prevent infinite recursion
248
+ return obj1 === obj2;
249
+ }
250
+ if (obj1 === obj2) {
251
+ return true;
252
+ }
253
+ if (obj1 == null || obj2 == null) {
254
+ return obj1 === obj2;
255
+ }
256
+ if (typeof obj1 !== typeof obj2) {
257
+ return false;
258
+ }
259
+ if (isPrimitive$1(obj1) || isPrimitive$1(obj2)) {
260
+ return obj1 === obj2;
261
+ }
262
+ // Handle arrays early for performance (common in forms)
263
+ if (Array.isArray(obj1) !== Array.isArray(obj2)) {
264
+ return false;
265
+ }
266
+ if (Array.isArray(obj1)) {
267
+ // We know obj2 is also an array here
268
+ const arr2 = obj2;
269
+ if (obj1.length !== arr2.length) {
270
+ return false;
271
+ }
272
+ for (let i = 0; i < obj1.length; i++) {
273
+ if (!fastDeepEqual(obj1[i], arr2[i], maxDepth - 1)) {
274
+ return false;
885
275
  }
886
276
  }
887
- return merged.length > 0 ? merged.join(' ') : null;
277
+ return true;
888
278
  }
889
- constructor() {
890
- this.errorDisplay = inject(FormErrorDisplayDirective, {
891
- self: true,
892
- });
893
- this.elementRef = inject((ElementRef));
894
- this.destroyRef = inject(DestroyRef);
895
- /**
896
- * Controls how this directive applies ARIA attributes to descendant controls.
897
- *
898
- * - `all-controls` (default): apply ARIA attributes to all input/select/textarea descendants.
899
- * - `single-control`: apply ARIA attributes only when exactly one control is found.
900
- * - `none`: do not mutate descendant controls.
901
- */
902
- this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
903
- /**
904
- * Unique ID prefix for this instance.
905
- * Use these IDs to render message regions and to support aria-describedby.
906
- */
907
- this.uniqueId = `ngx-error-control-${nextUniqueId++}`;
908
- this.errorId = `${this.uniqueId}-error`;
909
- this.warningId = `${this.uniqueId}-warning`;
910
- this.pendingId = `${this.uniqueId}-pending`;
911
- this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
912
- this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
913
- this.mutationObserver = null;
914
- this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
915
- this.showPendingMessage = this.pendingState.showPendingMessage;
916
- /**
917
- * aria-describedby value representing the *currently relevant* message regions.
918
- */
919
- this.ariaDescribedBy = computed(() => {
920
- const ids = [];
921
- if (this.errorDisplay.shouldShowErrors()) {
922
- ids.push(this.errorId);
923
- }
924
- if (this.errorDisplay.warnings().length > 0) {
925
- ids.push(this.warningId);
926
- }
927
- if (this.showPendingMessage()) {
928
- ids.push(this.pendingId);
929
- }
930
- return ids.length > 0 ? ids.join(' ') : null;
931
- }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
932
- this.ownedDescribedByIds = [
933
- this.errorId,
934
- this.warningId,
935
- this.pendingId,
936
- ];
937
- effect(() => {
938
- if (!this.contentInitialized())
939
- return;
940
- const mode = this.ariaAssociationMode();
941
- if (mode === 'none')
942
- return;
943
- const describedBy = this.ariaDescribedBy();
944
- const activeIds = describedBy
945
- ? describedBy.split(/\s+/).filter(Boolean)
946
- : [];
947
- const shouldShowErrors = this.errorDisplay.shouldShowErrors();
948
- const targets = (() => {
949
- const controls = this.formControls();
950
- if (mode === 'single-control') {
951
- return controls.length === 1 ? controls : [];
952
- }
953
- return controls;
954
- })();
955
- for (const control of targets) {
956
- const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), activeIds);
957
- if (nextDescribedBy) {
958
- control.setAttribute('aria-describedby', nextDescribedBy);
959
- }
960
- else {
961
- control.removeAttribute('aria-describedby');
962
- }
963
- if (shouldShowErrors) {
964
- control.setAttribute('aria-invalid', 'true');
965
- }
966
- else {
967
- control.removeAttribute('aria-invalid');
968
- }
969
- }
970
- });
971
- effect(() => {
972
- if (!this.contentInitialized())
973
- return;
974
- const mode = this.ariaAssociationMode();
975
- if (mode === 'none') {
976
- this.mutationObserver?.disconnect();
977
- this.mutationObserver = null;
978
- if (this.formControls().length > 0) {
979
- this.formControls.set([]);
980
- }
981
- return;
982
- }
983
- this.updateFormControls();
984
- if (!this.mutationObserver) {
985
- this.mutationObserver = new MutationObserver(() => {
986
- this.updateFormControls();
987
- });
988
- this.mutationObserver.observe(this.elementRef.nativeElement, {
989
- childList: true,
990
- subtree: true,
991
- });
279
+ // Handle Date objects
280
+ if (obj1 instanceof Date) {
281
+ return obj2 instanceof Date && obj1.getTime() === obj2.getTime();
282
+ }
283
+ // Handle RegExp objects
284
+ if (obj1 instanceof RegExp) {
285
+ return (obj2 instanceof RegExp &&
286
+ obj1.source === obj2.source &&
287
+ obj1.flags === obj2.flags);
288
+ }
289
+ // Handle Set objects (common in forms)
290
+ if (obj1 instanceof Set) {
291
+ if (!(obj2 instanceof Set) || obj1.size !== obj2.size) {
292
+ return false;
293
+ }
294
+ for (const value of obj1) {
295
+ if (!obj2.has(value)) {
296
+ return false;
992
297
  }
993
- });
994
- this.destroyRef.onDestroy(() => {
995
- this.mutationObserver?.disconnect();
996
- this.mutationObserver = null;
997
- });
298
+ }
299
+ return true;
998
300
  }
999
- ngAfterContentInit() {
1000
- this.contentInitialized.set(true);
301
+ // Handle Map objects (common in forms)
302
+ if (obj1 instanceof Map) {
303
+ if (!(obj2 instanceof Map) || obj1.size !== obj2.size) {
304
+ return false;
305
+ }
306
+ for (const [key, value] of obj1) {
307
+ if (!obj2.has(key) ||
308
+ !fastDeepEqual(value, obj2.get(key), maxDepth - 1)) {
309
+ return false;
310
+ }
311
+ }
312
+ return true;
1001
313
  }
1002
- ngOnDestroy() {
1003
- this.mutationObserver?.disconnect();
1004
- this.mutationObserver = null;
314
+ // Handle plain objects
315
+ const keys1 = Object.keys(obj1);
316
+ const keys2 = Object.keys(obj2);
317
+ if (keys1.length !== keys2.length) {
318
+ return false;
1005
319
  }
1006
- updateFormControls() {
1007
- const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
1008
- this.formControls.set(Array.from(controls));
320
+ for (const key of keys1) {
321
+ if (!Object.hasOwn(obj2, key) ||
322
+ !fastDeepEqual(obj1[key], obj2[key], maxDepth - 1)) {
323
+ return false;
324
+ }
1009
325
  }
1010
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1011
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: FormErrorControlDirective, isStandalone: true, selector: "[formErrorControl], [ngxErrorControl]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorControl", "ngxErrorControl"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0 }); }
326
+ return true;
1012
327
  }
1013
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormErrorControlDirective, decorators: [{
1014
- type: Directive,
1015
- args: [{
1016
- selector: '[formErrorControl], [ngxErrorControl]',
1017
- exportAs: 'formErrorControl, ngxErrorControl',
1018
- hostDirectives: [
1019
- {
1020
- directive: FormErrorDisplayDirective,
1021
- inputs: ['errorDisplayMode'],
1022
- },
1023
- ],
1024
- }]
1025
- }], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
1026
328
 
1027
329
  const ROOT_FORM = 'rootForm';
1028
330
 
@@ -1268,7 +570,7 @@ function mergeValuesAndRawValues(form) {
1268
570
  mergeRecursive(value, rawValue);
1269
571
  return value;
1270
572
  }
1271
- function isPrimitive$1(value) {
573
+ function isPrimitive(value) {
1272
574
  return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
1273
575
  }
1274
576
  /**
@@ -1282,7 +584,7 @@ function isPrimitive$1(value) {
1282
584
  */
1283
585
  function cloneDeep(object) {
1284
586
  // Handle primitives (null, undefined, boolean, string, number, function)
1285
- if (isPrimitive$1(object)) {
587
+ if (isPrimitive(object)) {
1286
588
  return object;
1287
589
  }
1288
590
  // Handle Date
@@ -1411,1069 +713,2030 @@ function getAllFormErrors(form) {
1411
713
  return errors;
1412
714
  }
1413
715
 
1414
- const NGX_VEST_FORMS_ERRORS = {
1415
- EXTRA_PROPERTY: {
1416
- code: 'NGX-001',
1417
- message: (path) => `Shape mismatch: Property '${path}' is present in the form value but not defined in the form shape.`,
1418
- },
1419
- TYPE_MISMATCH: {
1420
- code: 'NGX-002',
1421
- message: (path, expected, actual) => `Type mismatch at '${path}': Expected '${expected}' but got '${actual}'.`,
1422
- },
1423
- CONTROL_NOT_FOUND: {
1424
- code: 'NGX-003',
1425
- message: (path) => `Control not found: Could not find form control at path '${path}'. Check your [ngModel] name attributes.`,
1426
- },
1427
- };
1428
- function logWarning(error, ...args) {
1429
- console.warn(`[${error.code}] ${error.message(...args)}\nCheck your [formShape] input and the initial [formValue].`);
1430
- }
1431
-
1432
716
  /**
1433
- * Injection token for configurable validation config debounce timing.
1434
- *
1435
- * This token allows you to configure the debounce time for validation config
1436
- * dependencies at the application, route, or component level.
1437
- *
1438
- * @example
1439
- * ```typescript
1440
- * /// Global configuration
1441
- * export const appConfig: ApplicationConfig = {
1442
- * providers: [
1443
- * {
1444
- * provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
1445
- * useValue: 200
1446
- * }
1447
- * ]
1448
- * };
717
+ * Validates a form value against a shape to catch typos in `name` or `ngModelGroup` attributes.
1449
718
  *
1450
- * /// Per-route configuration
1451
- * {
1452
- * path: 'checkout',
1453
- * component: CheckoutComponent,
1454
- * providers: [
1455
- * {
1456
- * provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
1457
- * useValue: 50
1458
- * }
1459
- * ]
1460
- * }
719
+ * **What it checks:**
720
+ * - Extra properties: Keys in formValue that don't exist in shape (likely typos)
721
+ * - Type mismatches: When formValue has an object but shape expects a primitive
1461
722
  *
1462
- * /// Per-component override
1463
- * @Component({
1464
- * providers: [
1465
- * {
1466
- * provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
1467
- * useValue: 0 // for testing
1468
- * }
1469
- * ]
1470
- * })
1471
- * export class TestFormComponent {}
1472
- * ```
723
+ * **What it does NOT check:**
724
+ * - Missing properties: Keys in shape that don't exist in formValue
725
+ * (forms build incrementally with `NgxDeepPartial`, and `@if` conditionally renders fields)
1473
726
  *
1474
- * @default 100ms - Maintains backward compatibility with existing behavior
1475
- */
1476
- const NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN = new InjectionToken('NgxValidationConfigDebounceTime', {
1477
- providedIn: 'root',
1478
- factory: () => 100,
1479
- });
1480
-
1481
- /**
1482
- * Type guard to check if a value is a primitive type.
727
+ * Only runs in development mode.
1483
728
  *
1484
- * @param value - The value to check
1485
- * @returns true if the value is a primitive (string, number, boolean, null, undefined, symbol, bigint)
729
+ * @param formVal - The current form value
730
+ * @param shape - The expected shape (created with `NgxDeepRequired<T>`)
1486
731
  */
1487
- function isPrimitive(value) {
1488
- return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
732
+ function validateShape(formVal, shape) {
733
+ if (isDevMode()) {
734
+ validateFormValueAgainstShape(formVal, shape);
735
+ }
1489
736
  }
1490
737
  /**
1491
- * @internal
1492
- * Internal utility for shallow equality checks.
1493
- *
1494
- * **Not intended for external use.** This function is used internally by the library
1495
- * for performance-critical operations. Consider using your own comparison logic or
1496
- * a library like lodash if you need shallow equality checks in your application.
1497
- *
1498
- * Optimized shallow equality check for objects.
1499
- *
1500
- * **Why this custom implementation is preferred:**
1501
- * - **Performance**: Direct property comparison is significantly faster than JSON.stringify
1502
- * - **Type Safety**: Handles null/undefined values correctly without serialization issues
1503
- * - **Accuracy**: Doesn't suffer from JSON.stringify limitations (undefined values, functions, symbols)
1504
- * - **Memory Efficient**: No temporary string creation or object serialization overhead
1505
- *
1506
- * **Use Cases:**
1507
- * - Form value change detection where only top-level properties matter
1508
- * - Quick object comparison in performance-critical code paths
1509
- * - Validation triggers where deep comparison is unnecessary
1510
- *
1511
- * **Performance Comparison:**
1512
- * ```typescript
1513
- * /// ❌ Slow: JSON.stringify approach
1514
- * JSON.stringify(obj1) === JSON.stringify(obj2)
1515
- *
1516
- * /// ✅ Fast: Direct property comparison
1517
- * shallowEqual(obj1, obj2)
1518
- * ```
1519
- *
1520
- * @param obj1 - First object to compare
1521
- * @param obj2 - Second object to compare
1522
- * @returns true if objects are shallowly equal (same keys and same values by reference)
738
+ * Recursively validates form value keys against the shape.
739
+ * Reports warnings for extra properties and type mismatches.
1523
740
  */
1524
- function shallowEqual(obj1, obj2) {
1525
- if (obj1 === obj2) {
1526
- return true;
1527
- }
1528
- if (obj1 == null || obj2 == null) {
1529
- return obj1 === obj2;
1530
- }
1531
- if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
1532
- return obj1 === obj2;
1533
- }
1534
- const keys1 = Object.keys(obj1);
1535
- const keys2 = Object.keys(obj2);
1536
- if (keys1.length !== keys2.length) {
1537
- return false;
1538
- }
1539
- for (const key of keys1) {
1540
- if (!Object.hasOwn(obj2, key) ||
1541
- obj1[key] !==
1542
- obj2[key]) {
1543
- return false;
741
+ function validateFormValueAgainstShape(formValue, shape, path = '') {
742
+ for (const key of Object.keys(formValue)) {
743
+ const value = formValue[key];
744
+ const fieldPath = path ? `${path}.${key}` : key;
745
+ // Skip null/undefined values (valid during form initialization)
746
+ if (value == null) {
747
+ continue;
748
+ }
749
+ // For array items (numeric keys > 0), compare against the first item in shape
750
+ // since we only define one example item in the shape for arrays
751
+ const isNumericKey = !isNaN(parseFloat(key));
752
+ const shapeKey = isNumericKey && parseFloat(key) > 0 ? '0' : key;
753
+ const shapeValue = shape?.[shapeKey];
754
+ // Skip Date fields receiving empty strings (common in date picker libraries)
755
+ if (shapeValue instanceof Date && value === '') {
756
+ continue;
757
+ }
758
+ // Handle object values (recurse into nested objects)
759
+ if (typeof value === 'object') {
760
+ // Type mismatch: formValue has object, but shape expects primitive
761
+ if (!isNumericKey &&
762
+ (typeof shapeValue !== 'object' || shapeValue === null)) {
763
+ logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
764
+ }
765
+ // Recurse into nested object
766
+ validateFormValueAgainstShape(value, shapeValue ?? {}, fieldPath);
767
+ continue;
768
+ }
769
+ // Extra property: key exists in formValue but not in shape (likely a typo)
770
+ if (!isNumericKey && shape && !(shapeKey in shape)) {
771
+ logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
1544
772
  }
1545
773
  }
1546
- return true;
1547
774
  }
775
+
1548
776
  /**
1549
- * @internal
1550
- * Internal utility for deep equality checks.
1551
- *
1552
- * **Not intended for external use.** This function is used internally by the library
1553
- * for form value comparison and change detection. Consider using your own comparison
1554
- * logic or a library like lodash if you need deep equality checks in your application.
1555
- *
1556
- * Fast deep equality check optimized for form values and Angular applications.
1557
- *
1558
- * **Why this custom implementation is preferred over alternatives:**
1559
- *
1560
- * **vs JSON.stringify():**
1561
- * - **10-100x faster**: Direct comparison without string serialization overhead
1562
- * - **Accurate**: Handles Date objects, RegExp, undefined values, and functions correctly
1563
- * - **Memory efficient**: No temporary string creation or garbage collection pressure
1564
- * - **Preserves semantics**: Maintains type information during comparison
1565
- *
1566
- * **vs structuredClone():**
1567
- * - **Wrong purpose**: structuredClone creates copies, not comparisons
1568
- * - **Performance**: Would require cloning both objects just to compare them
1569
- * - **Memory waste**: Creates unnecessary deep copies, doubling memory usage
1570
- * - **Still incomplete**: Even after cloning, you'd still need a comparison function
1571
- *
1572
- * **vs External libraries (lodash.isEqual, etc.):**
1573
- * - **Bundle size**: Zero dependencies, smaller application bundles
1574
- * - **Form-specific**: Optimized for common Angular form data patterns
1575
- * - **Type safety**: Full TypeScript integration with strict typing
1576
- * - **Performance**: Tailored algorithms for form value comparison use cases
777
+ * Duration (in milliseconds) to keep fields marked as "in-progress" after validation.
778
+ * This prevents immediate re-triggering of bidirectional validations.
779
+ * Increased from 100ms to 500ms to give validators enough time to complete and propagate.
780
+ */
781
+ const VALIDATION_IN_PROGRESS_TIMEOUT_MS = 500;
782
+ /**
783
+ * Main form directive for ngx-vest-forms that bridges Angular template-driven forms with Vest.js validation.
1577
784
  *
1578
- * **Supported Data Types:**
1579
- * - Primitives (string, number, boolean, null, undefined, symbol, bigint)
1580
- * - Arrays (with recursive deep comparison)
1581
- * - Plain objects (with recursive deep comparison)
1582
- * - Date objects (by timestamp comparison)
1583
- * - RegExp objects (by source and flags comparison)
1584
- * - Set objects (by size and value membership)
1585
- * - Map objects (by size and key-value pairs)
785
+ * This directive provides:
786
+ * - **Unidirectional data flow**: Use `[ngModel]` (not `[(ngModel)]`) with `(formValueChange)` for predictable state updates
787
+ * - **Vest.js integration**: Automatic async validators from Vest suites with field-level optimization
788
+ * - **Validation dependencies**: Configure cross-field validation triggers via `validationConfig`
789
+ * - **Form state**: Access validity, errors, and values through the `formState` signal
1586
790
  *
1587
- * **Safety Features:**
1588
- * - **Circular reference protection**: MaxDepth parameter prevents infinite recursion
1589
- * - **Type coercion prevention**: Strict type checking before comparison
1590
- * - **Null safety**: Proper handling of null and undefined values
791
+ * @usageNotes
1591
792
  *
1592
- * **Performance Characteristics:**
1593
- * ```typescript
1594
- * /// Performance comparison on typical form objects:
1595
- * /// JSON.stringify: ~100ms for complex nested forms
1596
- * /// fastDeepEqual: ~1-5ms for the same objects
1597
- * ///
1598
- * /// Memory usage:
1599
- * /// JSON.stringify: Creates temporary strings (high GC pressure)
1600
- * /// fastDeepEqual: Zero allocations during comparison
793
+ * ### Basic Usage
794
+ * ```html
795
+ * <form ngxVestForm [suite]="validationSuite" (formValueChange)="formValue.set($event)">
796
+ * <input name="email" [ngModel]="formValue().email" />
797
+ * </form>
1601
798
  * ```
1602
799
  *
1603
- * **Typical Usage in Forms:**
800
+ * ### With Validation Dependencies
801
+ * ```html
802
+ * <form ngxVestForm [suite]="suite" [validationConfig]="validationConfig">
803
+ * <input name="password" [ngModel]="formValue().password" />
804
+ * <input name="confirmPassword" [ngModel]="formValue().confirmPassword" />
805
+ * </form>
806
+ * ```
1604
807
  * ```typescript
1605
- * /// Detect when form values actually change
1606
- * distinctUntilChanged(fastDeepEqual)
808
+ * validationConfig = { 'password': ['confirmPassword'] };
809
+ * ```
1607
810
  *
1608
- * /// Prevent unnecessary re-renders
1609
- * if (!fastDeepEqual(oldFormValue, newFormValue)) {
1610
- * updateUI();
1611
- * }
811
+ * ### Accessing Form State
812
+ * ```typescript
813
+ * vestForm = viewChild.required('vestForm', { read: FormDirective });
814
+ * isValid = computed(() => this.vestForm().formState().valid);
1612
815
  * ```
1613
816
  *
1614
- * @param obj1 - First object to compare
1615
- * @param obj2 - Second object to compare
1616
- * @param maxDepth - Maximum recursion depth to prevent infinite loops (default: 10)
1617
- * @returns true if objects are deeply equal by value
817
+ * @see {@link https://github.com/ngx-vest-forms/ngx-vest-forms} for full documentation
818
+ * @publicApi
1618
819
  */
1619
- function fastDeepEqual(obj1, obj2, maxDepth = 10) {
1620
- if (maxDepth <= 0) {
1621
- // Fallback to shallow comparison at max depth to prevent infinite recursion
1622
- return obj1 === obj2;
820
+ class FormDirective {
821
+ // Track last linked value to prevent unnecessary updates
822
+ #lastLinkedValue;
823
+ #lastSyncedFormValue;
824
+ #lastSyncedModelValue;
825
+ // Internal signal tracking form value changes via statusChanges
826
+ #value;
827
+ /**
828
+ * LinkedSignal that computes form values from Angular form state.
829
+ * This eliminates timing issues with the previous dual-effect pattern.
830
+ */
831
+ #formValueSignal;
832
+ /**
833
+ * Track the Angular form status as a signal for advanced status flags
834
+ */
835
+ #statusSignal;
836
+ constructor() {
837
+ this.ngForm = inject(NgForm, { self: true });
838
+ this.destroyRef = inject(DestroyRef);
839
+ this.cdr = inject(ChangeDetectorRef);
840
+ this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
841
+ /**
842
+ * Public signal storing field warnings keyed by field path.
843
+ * This allows warnings to be stored and displayed without affecting field validity.
844
+ * Angular's control.errors !== null marks a field as invalid, so we store warnings
845
+ * separately when they exist without errors.
846
+ */
847
+ this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : []));
848
+ // Track last linked value to prevent unnecessary updates
849
+ this.#lastLinkedValue = null;
850
+ this.#lastSyncedFormValue = null;
851
+ this.#lastSyncedModelValue = null;
852
+ // Internal signal tracking form value changes via statusChanges
853
+ this.#value = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
854
+ /**
855
+ * LinkedSignal that computes form values from Angular form state.
856
+ * This eliminates timing issues with the previous dual-effect pattern.
857
+ */
858
+ this.#formValueSignal = linkedSignal(() => {
859
+ // Track form value changes
860
+ this.#value();
861
+ const raw = mergeValuesAndRawValues(this.ngForm.form);
862
+ if (Object.keys(this.ngForm.form.controls).length > 0) {
863
+ this.#lastLinkedValue = raw;
864
+ return raw;
865
+ }
866
+ else if (this.#lastLinkedValue !== null) {
867
+ return this.#lastLinkedValue;
868
+ }
869
+ return null;
870
+ }, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : []));
871
+ /**
872
+ * Track the Angular form status as a signal for advanced status flags
873
+ */
874
+ this.#statusSignal = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
875
+ /**
876
+ * Computed signal for form state with validity and errors.
877
+ * Used by templates and tests as vestForm.formState().valid/errors
878
+ *
879
+ * Uses custom equality function to prevent unnecessary recalculations
880
+ * when form status changes but actual values/errors remain the same.
881
+ */
882
+ this.formState = computed(() => {
883
+ // Tie to status signal to ensure recomputation on validation changes
884
+ this.#statusSignal();
885
+ return {
886
+ valid: this.ngForm.form.valid,
887
+ errors: getAllFormErrors(this.ngForm.form),
888
+ value: this.#formValueSignal(),
889
+ };
890
+ }, { ...(ngDevMode ? { debugName: "formState" } : {}), equal: (a, b) => {
891
+ // Fast path: reference equality
892
+ if (a === b)
893
+ return true;
894
+ // Null/undefined check
895
+ if (!a || !b)
896
+ return false;
897
+ // Deep equality check for form state properties
898
+ return (a.valid === b.valid &&
899
+ fastDeepEqual(a.errors, b.errors) &&
900
+ fastDeepEqual(a.value, b.value));
901
+ } });
902
+ /**
903
+ * The value of the form, this is needed for the validation part.
904
+ * Using input() here because two-way binding is provided via formValueChange output.
905
+ * In the minimal core directive (form-core.directive.ts), this would be model() instead.
906
+ */
907
+ this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
908
+ /**
909
+ * Static vest suite that will be used to feed our angular validators.
910
+ * Accepts both NgxVestSuite and NgxTypedVestSuite through compatible type signatures.
911
+ * NgxTypedVestSuite<T> is assignable to NgxVestSuite<T> due to bivariance and
912
+ * FormFieldName<T> (string literal union) being assignable to string.
913
+ */
914
+ this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
915
+ /**
916
+ * The shape of our form model. This is a deep required version of the form model
917
+ * The goal is to add default values to the shape so when the template-driven form
918
+ * contains values that shouldn't be there (typo's) that the developer gets run-time
919
+ * errors in dev mode
920
+ */
921
+ this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : []));
922
+ /**
923
+ * Updates the validation config which is a dynamic object that will be used to
924
+ * trigger validations on the dependant fields
925
+ * Eg: ```typescript
926
+ * validationConfig = {
927
+ * 'passwords.password': ['passwords.confirmPassword']
928
+ * }
929
+ * ```
930
+ *
931
+ * This will trigger the updateValueAndValidity on passwords.confirmPassword every time the passwords.password gets a new value
932
+ *
933
+ * @param v
934
+ */
935
+ this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : []));
936
+ this.pending$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v === 'PENDING'), distinctUntilChanged());
937
+ /**
938
+ * Emits every time the form status changes in a state
939
+ * that is not PENDING
940
+ * We need this to assure that the form is in 'idle' state
941
+ */
942
+ this.idle$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING'), distinctUntilChanged());
943
+ /**
944
+ * Triggered as soon as the form value changes
945
+ * It also contains the disabled values (raw values)
946
+ *
947
+ * Cleanup is handled automatically by the directive when it's destroyed.
948
+ */
949
+ this.formValueChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof ValueChangeEvent), map((v) => v.value), distinctUntilChanged((prev, curr) => {
950
+ // Use efficient deep equality instead of JSON.stringify for better performance
951
+ return fastDeepEqual(prev, curr);
952
+ }), map(() => mergeValuesAndRawValues(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
953
+ /**
954
+ * Emits an object with all the errors of the form
955
+ * every time a form control or form groups changes its status to valid or invalid
956
+ *
957
+ * For submit events, waits for async validation (including ROOT_FORM) to complete
958
+ * before emitting errors. This ensures ROOT_FORM errors are included in the output.
959
+ *
960
+ * Cleanup is handled automatically by the directive when it's destroyed.
961
+ */
962
+ this.errorsChange = outputFromObservable(merge(
963
+ // Status change events (non-PENDING) - emit immediately
964
+ this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING')),
965
+ // Submit events - wait for async validation to complete before emitting
966
+ this.ngForm.ngSubmit.pipe(switchMap(() => {
967
+ // If form is PENDING (async validation in progress), wait for it to complete
968
+ if (this.ngForm.form.status === 'PENDING') {
969
+ return this.ngForm.form.statusChanges.pipe(filter((status) => status !== 'PENDING'), take(1));
970
+ }
971
+ // Form not pending, emit immediately
972
+ return of(this.ngForm.form.status);
973
+ }))).pipe(map(() => getAllFormErrors(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
974
+ /**
975
+ * Triggered as soon as the form becomes dirty
976
+ *
977
+ * Cleanup is handled automatically by the directive when it's destroyed.
978
+ */
979
+ this.dirtyChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof PristineChangeEvent), map((v) => !v.pristine), startWith(this.ngForm.form.dirty), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
980
+ /**
981
+ * Fired when the status of the root form changes.
982
+ */
983
+ this.statusChanges$ = this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status), distinctUntilChanged());
984
+ /**
985
+ * Triggered When the form becomes valid but waits until the form is idle
986
+ *
987
+ * Cleanup is handled automatically by the directive when it's destroyed.
988
+ */
989
+ this.validChange = outputFromObservable(this.statusChanges$.pipe(filter((e) => e === 'VALID' || e === 'INVALID'), map((v) => v === 'VALID'), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
990
+ /**
991
+ * Track validation in progress to prevent circular triggering (Issue #19)
992
+ */
993
+ this.validationInProgress = new Set();
994
+ this.destroyRef.onDestroy(() => {
995
+ this.fieldWarnings.set(new Map());
996
+ });
997
+ /**
998
+ * Trigger shape validations if the form gets updated
999
+ * This is how we can throw run-time errors
1000
+ */
1001
+ if (isDevMode()) {
1002
+ effect(() => {
1003
+ const v = this.formValue();
1004
+ if (v && this.formShape()) {
1005
+ validateShape(v, this.formShape());
1006
+ }
1007
+ });
1008
+ }
1009
+ /**
1010
+ * Mark all the fields as touched when the form is submitted
1011
+ */
1012
+ this.ngForm.ngSubmit
1013
+ .pipe(takeUntilDestroyed(this.destroyRef))
1014
+ .subscribe(() => {
1015
+ this.ngForm.form.markAllAsTouched();
1016
+ });
1017
+ /**
1018
+ * Single bidirectional synchronization effect using linkedSignal.
1019
+ * Uses proper deep comparison and change tracking for correct sync direction.
1020
+ * Note: formValue is read-only input(), so we emit changes via formValueChange output.
1021
+ */
1022
+ effect(() => {
1023
+ const formValue = this.#formValueSignal();
1024
+ const modelValue = this.formValue();
1025
+ // Skip if either is null
1026
+ if (!formValue && !modelValue)
1027
+ return;
1028
+ // Compute change flags first
1029
+ const formChanged = !fastDeepEqual(formValue, this.#lastSyncedFormValue);
1030
+ const modelChanged = !fastDeepEqual(modelValue, this.#lastSyncedModelValue);
1031
+ // Early return if nothing changed
1032
+ if (!formChanged && !modelChanged) {
1033
+ return;
1034
+ }
1035
+ if (formChanged && !modelChanged) {
1036
+ // Form was modified by user -> form wins
1037
+ // Note: We can't call this.formValue.set() since it's an input()
1038
+ // The formValueChange output will emit the new value
1039
+ // Use untracked() to avoid infinite loops - we're updating tracking state here
1040
+ untracked(() => {
1041
+ this.#lastSyncedFormValue = formValue;
1042
+ this.#lastSyncedModelValue = formValue;
1043
+ });
1044
+ }
1045
+ else if (modelChanged && !formChanged) {
1046
+ // Model was modified programmatically -> model wins
1047
+ // Use untracked() to avoid infinite loops - we're updating tracking state here
1048
+ untracked(() => {
1049
+ // Update form controls with new model values
1050
+ if (modelValue) {
1051
+ // IMPORTANT: Use root patchValue instead of per-key setValue.
1052
+ // - Supports nested objects (ngModelGroup) without throwing when partial objects are provided.
1053
+ // - patchValue ignores missing controls/keys, which is compatible with DeepPartial form models.
1054
+ // - emitEvent:false prevents feedback loops; validation still updates internally.
1055
+ this.ngForm.form.patchValue(modelValue, { emitEvent: false });
1056
+ }
1057
+ this.#lastSyncedFormValue = modelValue;
1058
+ this.#lastSyncedModelValue = modelValue;
1059
+ });
1060
+ }
1061
+ else if (formChanged && modelChanged) {
1062
+ // Both form and model changed simultaneously
1063
+ // Check if they changed to the same value (synchronized change) or different values (conflict)
1064
+ const valuesEqual = fastDeepEqual(formValue, modelValue);
1065
+ if (valuesEqual) {
1066
+ // Both changed to the same value - this is a synchronized change, not a conflict
1067
+ // Just update tracking to acknowledge the change
1068
+ untracked(() => {
1069
+ this.#lastSyncedFormValue = formValue;
1070
+ this.#lastSyncedModelValue = formValue;
1071
+ });
1072
+ }
1073
+ else {
1074
+ // Both changed to different values - this is a true conflict
1075
+ // This is an edge case that should rarely happen in practice.
1076
+ // We intentionally do nothing here to avoid breaking the Angular event flow.
1077
+ // The form will continue with its current values, and validation will run normally.
1078
+ // The next change (either form or model) will trigger proper synchronization.
1079
+ }
1080
+ }
1081
+ });
1082
+ // Set up validation config reactively
1083
+ this.#setupValidationConfig();
1623
1084
  }
1624
- if (obj1 === obj2) {
1625
- return true;
1085
+ /**
1086
+ * Manually trigger form validation update.
1087
+ *
1088
+ * This is useful when form structure changes but no control values change,
1089
+ * which means validation state might be stale. This method forces a re-evaluation
1090
+ * of all form validators and updates the form validity state.
1091
+ *
1092
+ * **IMPORTANT: This method validates ALL form fields by design.**
1093
+ * This is intentional for structure changes as conditional validators may now
1094
+ * apply to different fields, requiring a complete validation refresh.
1095
+ *
1096
+ * **CRITICAL: This method does NOT mark fields as touched or show errors.**
1097
+ * It only re-runs validation logic. To show all errors (e.g., on submit),
1098
+ * use `markAllAsTouched()` instead or in combination.
1099
+ *
1100
+ * **When to use each:**
1101
+ * - `triggerFormValidation()` - Re-run validation when structure changes
1102
+ * - `markAllAsTouched()` - Show all errors to user (e.g., on submit)
1103
+ * - Both together - Rare, only if structure changed AND you want to show errors
1104
+ *
1105
+ * **Note on form submission:**
1106
+ * When using the default error display mode (`on-blur-or-submit`), you typically
1107
+ * don't need to call this method on submit. The form directive automatically marks
1108
+ * all fields as touched on `ngSubmit`, and errors will display automatically.
1109
+ * Only use this method when form structure changes without value changes.
1110
+ *
1111
+ * **Use Cases:**
1112
+ * - Conditionally showing/hiding form controls based on other field values
1113
+ * - Adding or removing form controls dynamically
1114
+ * - Switching between different form layouts where validation requirements change
1115
+ * - Any scenario where form structure changes but no ValueChangeEvent is triggered
1116
+ *
1117
+ * **Example:**
1118
+ * When switching from a form with required input fields to one with only informational content,
1119
+ * the form should become valid, but this won't happen automatically
1120
+ * when no value changes occur (e.g., switching from input fields to informational content).
1121
+ *
1122
+ * **Performance Note:**
1123
+ * This method calls `updateValueAndValidity({ emitEvent: true })` on the root form,
1124
+ * which validates all form controls. For large forms, consider if more granular
1125
+ * validation updates are possible.
1126
+ *
1127
+ * @example
1128
+ * ```typescript
1129
+ * /// After changing form structure
1130
+ * onProcedureTypeChange(newType: string) {
1131
+ * this.procedureType.set(newType);
1132
+ * /// Structure changed but no control values changed
1133
+ * this.formDirective.triggerFormValidation();
1134
+ * }
1135
+ *
1136
+ * /// For submit with multiple forms (show all errors)
1137
+ * submitAll() {
1138
+ * // Mark all as touched to show errors
1139
+ * this.form1Ref().markAllAsTouched();
1140
+ * this.form2Ref().markAllAsTouched();
1141
+ * // Only needed if structure changed without value changes
1142
+ * // this.form1Ref().triggerFormValidation();
1143
+ * // this.form2Ref().triggerFormValidation();
1144
+ * }
1145
+ * ```
1146
+ */
1147
+ triggerFormValidation(path) {
1148
+ if (path) {
1149
+ const control = this.ngForm.form.get(path);
1150
+ if (control) {
1151
+ control.updateValueAndValidity({ emitEvent: true });
1152
+ }
1153
+ else if (isDevMode()) {
1154
+ logWarning(NGX_VEST_FORMS_ERRORS.CONTROL_NOT_FOUND, path);
1155
+ }
1156
+ }
1157
+ else {
1158
+ // Update all form controls validity which will trigger all form events
1159
+ this.ngForm.form.updateValueAndValidity({ emitEvent: true });
1160
+ }
1626
1161
  }
1627
- if (obj1 == null || obj2 == null) {
1628
- return obj1 === obj2;
1162
+ /**
1163
+ * Convenience method to mark all form controls as touched.
1164
+ *
1165
+ * This is useful for showing all validation errors at once, typically when
1166
+ * the user clicks a submit button. When a field is marked as touched,
1167
+ * the error display logic (based on `errorDisplayMode`) will show its errors.
1168
+ *
1169
+ * **Note on automatic behavior:**
1170
+ * When using the default error display mode (`on-blur-or-submit`), you typically
1171
+ * don't need to call this method manually for regular form submissions. The form
1172
+ * directive automatically marks all fields as touched on `ngSubmit`, so errors
1173
+ * will display automatically when the user submits the form.
1174
+ *
1175
+ * **When to use this method:**
1176
+ * - Multiple forms with a single submit button (forms without their own submit)
1177
+ * - Programmatic form submission without triggering `ngSubmit`
1178
+ * - Custom validation flows outside the normal submit process
1179
+ *
1180
+ * **Note:** This method only marks fields as touched—it does NOT re-run validation.
1181
+ * If you also need to re-run validation (e.g., after structure changes), call
1182
+ * `triggerFormValidation()` as well.
1183
+ *
1184
+ * @example
1185
+ * ```typescript
1186
+ * /// Standard form submission - NO need to call markAllAsTouched()
1187
+ * /// The directive handles this automatically on ngSubmit
1188
+ * <form ngxVestForm (ngSubmit)="save()">
1189
+ * <button type="submit">Submit</button>
1190
+ * </form>
1191
+ *
1192
+ * /// Multiple forms with one submit button
1193
+ * submitAll() {
1194
+ * this.form1().markAllAsTouched();
1195
+ * this.form2().markAllAsTouched();
1196
+ * if (this.form1().formState().valid && this.form2().formState().valid) {
1197
+ * /// Submit all forms
1198
+ * }
1199
+ * }
1200
+ * ```
1201
+ */
1202
+ markAllAsTouched() {
1203
+ this.ngForm.form.markAllAsTouched();
1629
1204
  }
1630
- if (typeof obj1 !== typeof obj2) {
1631
- return false;
1205
+ /**
1206
+ * Resets the form to a pristine, untouched state with optional new values.
1207
+ *
1208
+ * This method properly resets the form by:
1209
+ * 1. Resetting Angular's underlying NgForm with the provided value
1210
+ * 2. Clearing the bidirectional sync tracking state
1211
+ * 3. Forcing a form validity update to clear any stale validation errors
1212
+ *
1213
+ * **Why this method exists:**
1214
+ * When using the pattern `formValue.set({})` to reset a form, there can be a timing
1215
+ * issue where the form controls in the DOM still hold their old values while the
1216
+ * signal has already been updated. This creates a conflict in the bidirectional
1217
+ * sync logic, requiring workarounds like calling `formValue.set({})` twice with
1218
+ * a setTimeout. This method provides a proper solution by:
1219
+ * - Calling Angular's `NgForm.resetForm()` which properly clears all controls
1220
+ * - Clearing the internal sync tracking state to avoid stale comparisons
1221
+ * - Triggering a form validity update to ensure validation state is current
1222
+ *
1223
+ * **Usage:**
1224
+ * Instead of the double-set workaround:
1225
+ * ```typescript
1226
+ * // ❌ Old workaround (avoid)
1227
+ * reset(): void {
1228
+ * this.formValue.set({});
1229
+ * setTimeout(() => this.formValue.set({}), 0);
1230
+ * }
1231
+ *
1232
+ * // ✅ Preferred approach
1233
+ * vestForm = viewChild.required('vestForm', { read: FormDirective });
1234
+ * reset(): void {
1235
+ * this.formValue.set({});
1236
+ * this.vestForm().resetForm();
1237
+ * }
1238
+ * ```
1239
+ *
1240
+ * **With new values:**
1241
+ * ```typescript
1242
+ * // Reset and set new initial values
1243
+ * resetWithDefaults(): void {
1244
+ * const defaults = { firstName: '', lastName: '', age: 18 };
1245
+ * this.formValue.set(defaults);
1246
+ * this.vestForm().resetForm(defaults);
1247
+ * }
1248
+ * ```
1249
+ *
1250
+ * @param value - Optional new value to reset the form to. If not provided,
1251
+ * resets to empty/default values.
1252
+ *
1253
+ * @see {@link markAllAsTouched} for showing validation errors
1254
+ * @see {@link triggerFormValidation} for re-running validation without reset
1255
+ */
1256
+ resetForm(value) {
1257
+ // Reset Angular's form to clear all controls and mark as pristine/untouched
1258
+ this.ngForm.resetForm(value ?? undefined);
1259
+ // Clear any stored warnings to avoid stale messages after reset
1260
+ this.fieldWarnings.set(new Map());
1261
+ // Clear the bidirectional sync tracking state so the next formValue change
1262
+ // is treated as a model change (not a conflict with stale form values)
1263
+ this.#lastSyncedFormValue = null;
1264
+ this.#lastSyncedModelValue = null;
1265
+ this.#lastLinkedValue = null;
1266
+ // Force change detection to ensure DOM updates are reflected
1267
+ // Note: This is still needed even with signals because we're modifying NgForm
1268
+ // (reactive forms), not signals. The formValue signal updates happen in the
1269
+ // consumer component. detectChanges() ensures NgForm's reset is reflected in
1270
+ // the DOM before we update validity.
1271
+ this.cdr.detectChanges();
1272
+ // Trigger validation update to clear any stale errors
1273
+ // Now synchronous since detectChanges() has flushed DOM updates
1274
+ this.ngForm.form.updateValueAndValidity({ emitEvent: true });
1632
1275
  }
1633
- if (isPrimitive(obj1) || isPrimitive(obj2)) {
1634
- return obj1 === obj2;
1276
+ /**
1277
+ * This will feed the formValueCache, debounce it till the next tick
1278
+ * and create an asynchronous validator that runs a vest suite
1279
+ * @param field
1280
+ * @param validationOptions
1281
+ * @returns an asynchronous validator function
1282
+ */
1283
+ /**
1284
+ * V2 async validator pattern: uses timer() + switchMap for proper re-validation.
1285
+ * Each invocation builds a fresh one-shot observable, ensuring progressive validation.
1286
+ */
1287
+ createAsyncValidator(field, validationOptions) {
1288
+ const suite = this.suite();
1289
+ if (!suite)
1290
+ return () => of(null);
1291
+ return (control) => {
1292
+ const model = mergeValuesAndRawValues(this.ngForm.form);
1293
+ // Targeted snapshot with candidate value injected at path
1294
+ // mergeValuesAndRawValues already returns a deep clone (via structuredClone),
1295
+ // so we can modify it directly without affecting the form state.
1296
+ const snapshot = model;
1297
+ setValueAtPath(snapshot, field, control.value);
1298
+ // Use timer() instead of ReplaySubject for proper debouncing
1299
+ return timer(validationOptions.debounceTime ?? 0).pipe(map(() => snapshot), switchMap((snap) => new Observable((observer) => {
1300
+ try {
1301
+ // Cast to NgxVestSuite to accept string field parameter
1302
+ // Both NgxVestSuite and NgxTypedVestSuite work with string at runtime
1303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1304
+ suite(snap, field).done((result) => {
1305
+ const errors = result.getErrors()[field];
1306
+ const warnings = result.getWarnings()[field];
1307
+ // Store warnings in the fieldWarnings signal for access by control wrappers.
1308
+ // This is necessary because Angular marks a field as invalid when control.errors !== null.
1309
+ // By storing warnings separately, fields can remain valid while still displaying warnings.
1310
+ this.fieldWarnings.update((map) => {
1311
+ const newMap = new Map(map);
1312
+ if (warnings?.length) {
1313
+ newMap.set(field, warnings);
1314
+ }
1315
+ else {
1316
+ newMap.delete(field);
1317
+ }
1318
+ return newMap;
1319
+ });
1320
+ // Build the validation result:
1321
+ // - Errors exist → return { errors, warnings? } (field invalid, Angular shows ng-invalid)
1322
+ // - Only warnings → return null (field valid, warnings accessed via fieldWarnings signal)
1323
+ // - Neither → return null (field valid)
1324
+ //
1325
+ // When errors exist, we also include warnings in control.errors for backwards compatibility
1326
+ // with code that reads warnings from control.errors.warnings.
1327
+ const out = errors?.length
1328
+ ? {
1329
+ errors,
1330
+ ...(warnings?.length && { warnings }),
1331
+ }
1332
+ : null;
1333
+ // CRITICAL: Ensure DOM validity classes update for OnPush components.
1334
+ //
1335
+ // Angular's template-driven forms update `ng-valid`/`ng-invalid` host classes
1336
+ // during change detection. When async validation completes, there may be no
1337
+ // follow-up change detection pass for OnPush hosts, leaving the DOM in a stale
1338
+ // visual state (even though the control status has updated).
1339
+ //
1340
+ // We schedule a detectChanges() on the next microtask to avoid calling it
1341
+ // synchronously inside Angular's own validation pipeline.
1342
+ queueMicrotask(() => {
1343
+ try {
1344
+ this.cdr.detectChanges();
1345
+ }
1346
+ catch {
1347
+ // Fallback: mark for check when immediate detectChanges isn't safe.
1348
+ // This keeps behavior resilient in edge cases.
1349
+ this.cdr.markForCheck();
1350
+ }
1351
+ });
1352
+ observer.next(out);
1353
+ observer.complete();
1354
+ });
1355
+ }
1356
+ catch {
1357
+ observer.next({ vestInternalError: 'Validation failed' });
1358
+ observer.complete();
1359
+ }
1360
+ })), catchError(() => of({ vestInternalError: 'Validation failed' })), take(1));
1361
+ };
1635
1362
  }
1636
- // Handle arrays early for performance (common in forms)
1637
- if (Array.isArray(obj1) !== Array.isArray(obj2)) {
1638
- return false;
1363
+ /**
1364
+ * Set up validation config reactively using v2 pattern with toObservable + switchMap.
1365
+ * This provides automatic cleanup when config changes.
1366
+ */
1367
+ #setupValidationConfig() {
1368
+ const form = this.ngForm.form;
1369
+ toObservable(this.validationConfig)
1370
+ .pipe(distinctUntilChanged(), switchMap((config) => {
1371
+ // Cast to the expected type for the helper method
1372
+ const typedConfig = config;
1373
+ return this.#createValidationStreams(form, typedConfig);
1374
+ }), takeUntilDestroyed(this.destroyRef))
1375
+ .subscribe();
1639
1376
  }
1640
- if (Array.isArray(obj1)) {
1641
- // We know obj2 is also an array here
1642
- const arr2 = obj2;
1643
- if (obj1.length !== arr2.length) {
1644
- return false;
1645
- }
1646
- for (let i = 0; i < obj1.length; i++) {
1647
- if (!fastDeepEqual(obj1[i], arr2[i], maxDepth - 1)) {
1648
- return false;
1649
- }
1377
+ /**
1378
+ * Creates validation streams for the provided configuration.
1379
+ * Returns EMPTY if config is null/undefined, otherwise merges all trigger field streams.
1380
+ *
1381
+ * @param form - The NgForm instance
1382
+ * @param config - The validation configuration mapping trigger fields to dependent fields
1383
+ * @returns Observable that emits when any trigger field changes and dependent fields need validation
1384
+ */
1385
+ #createValidationStreams(form, config) {
1386
+ if (!config) {
1387
+ this.validationInProgress.clear();
1388
+ return EMPTY;
1650
1389
  }
1651
- return true;
1652
- }
1653
- // Handle Date objects
1654
- if (obj1 instanceof Date) {
1655
- return obj2 instanceof Date && obj1.getTime() === obj2.getTime();
1656
- }
1657
- // Handle RegExp objects
1658
- if (obj1 instanceof RegExp) {
1659
- return (obj2 instanceof RegExp &&
1660
- obj1.source === obj2.source &&
1661
- obj1.flags === obj2.flags);
1390
+ const streams = Object.keys(config).map((triggerField) => {
1391
+ const dependents = config[triggerField] || [];
1392
+ return this.#createTriggerStream(form, triggerField, dependents);
1393
+ });
1394
+ return streams.length > 0 ? merge(...streams) : EMPTY;
1662
1395
  }
1663
- // Handle Set objects (common in forms)
1664
- if (obj1 instanceof Set) {
1665
- if (!(obj2 instanceof Set) || obj1.size !== obj2.size) {
1666
- return false;
1667
- }
1668
- for (const value of obj1) {
1669
- if (!obj2.has(value)) {
1670
- return false;
1671
- }
1672
- }
1673
- return true;
1396
+ /**
1397
+ * Creates a stream for a single trigger field that revalidates its dependent fields.
1398
+ *
1399
+ * This method handles:
1400
+ * 1. Waiting for the trigger control to exist in the form (for @if scenarios)
1401
+ * 2. Listening to value changes with debouncing
1402
+ * 3. Waiting for form to be idle before triggering dependents
1403
+ * 4. Waiting for all dependent controls to exist
1404
+ * 5. Updating dependent field validity with loop prevention
1405
+ * 6. Touch state propagation from trigger to dependents
1406
+ *
1407
+ * @param form - The NgForm instance
1408
+ * @param triggerField - Field path that triggers validation (e.g., 'password')
1409
+ * @param dependents - Array of dependent field paths to revalidate (e.g., ['confirmPassword'])
1410
+ * @returns Observable that completes after dependent fields are validated
1411
+ */
1412
+ #createTriggerStream(form, triggerField, dependents) {
1413
+ // Wait for trigger control to exist, then stop listening (take(1) prevents feedback loops)
1414
+ const triggerControl$ = form.statusChanges.pipe(startWith(form.status), map(() => form.get(triggerField)), filter((c) => !!c),
1415
+ // CRITICAL: take(1) to stop listening after control is found
1416
+ // Without this, the pipeline continues to listen to statusChanges,
1417
+ // creating a feedback loop where validation triggers re-trigger the pipeline
1418
+ take(1));
1419
+ return triggerControl$.pipe(switchMap((control) => {
1420
+ return control.valueChanges.pipe(
1421
+ // CRITICAL: Filter out changes when this field is being validated by another field's config
1422
+ // This prevents circular triggers in bidirectional validationConfig
1423
+ filter(() => !this.validationInProgress.has(triggerField)), debounceTime(this.configDebounceTime), switchMap(() => {
1424
+ return this.#waitForFormIdle(form, control);
1425
+ }), switchMap(() => this.#waitForDependentControls(form, dependents, control)), tap(() => this.#updateDependentFields(form, control, triggerField, dependents)));
1426
+ }));
1674
1427
  }
1675
- // Handle Map objects (common in forms)
1676
- if (obj1 instanceof Map) {
1677
- if (!(obj2 instanceof Map) || obj1.size !== obj2.size) {
1678
- return false;
1679
- }
1680
- for (const [key, value] of obj1) {
1681
- if (!obj2.has(key) ||
1682
- !fastDeepEqual(value, obj2.get(key), maxDepth - 1)) {
1683
- return false;
1684
- }
1428
+ /**
1429
+ * Waits for the form to reach a non-PENDING state before proceeding.
1430
+ * This prevents validation race conditions where dependent field validation
1431
+ * triggers while the trigger field's validation is still running.
1432
+ *
1433
+ * If the form stays PENDING for longer than 2 seconds (e.g., slow async validators),
1434
+ * proceeds anyway to prevent blocking the validation pipeline.
1435
+ *
1436
+ * @param form - The NgForm instance
1437
+ * @param control - The trigger control to pass through
1438
+ * @returns Observable that emits the control once form is idle or timeout
1439
+ */
1440
+ #waitForFormIdle(form, control) {
1441
+ // If form is already non-PENDING, return immediately
1442
+ if (form.status !== 'PENDING') {
1443
+ return of(control);
1685
1444
  }
1686
- return true;
1687
- }
1688
- // Handle plain objects
1689
- const keys1 = Object.keys(obj1);
1690
- const keys2 = Object.keys(obj2);
1691
- if (keys1.length !== keys2.length) {
1692
- return false;
1445
+ // Form is PENDING, wait for it to become idle
1446
+ const idle$ = form.statusChanges.pipe(filter((s) => s !== 'PENDING'), take(1));
1447
+ const timeout$ = timer(2000);
1448
+ return race(idle$, timeout$).pipe(map(() => control));
1693
1449
  }
1694
- for (const key of keys1) {
1695
- if (!Object.hasOwn(obj2, key) ||
1696
- !fastDeepEqual(obj1[key], obj2[key], maxDepth - 1)) {
1697
- return false;
1450
+ /**
1451
+ * Waits for all dependent controls to exist in the form.
1452
+ * This handles @if scenarios where controls are conditionally rendered.
1453
+ *
1454
+ * @param form - The NgForm instance
1455
+ * @param dependents - Array of dependent field paths
1456
+ * @param control - The trigger control to pass through
1457
+ * @returns Observable that emits the control once all dependents exist
1458
+ */
1459
+ #waitForDependentControls(form, dependents, control) {
1460
+ const allDependentsExist = dependents.every((depField) => !!form.get(depField));
1461
+ if (allDependentsExist) {
1462
+ return of(control);
1698
1463
  }
1464
+ // Wait for dependent controls to be added to the form
1465
+ return form.statusChanges.pipe(startWith(form.status), filter(() => dependents.every((depField) => !!form.get(depField))), take(1), map(() => control));
1699
1466
  }
1700
- return true;
1701
- }
1702
-
1703
- /**
1704
- * Validates a form value against a shape to catch typos in `name` or `ngModelGroup` attributes.
1705
- *
1706
- * **What it checks:**
1707
- * - Extra properties: Keys in formValue that don't exist in shape (likely typos)
1708
- * - Type mismatches: When formValue has an object but shape expects a primitive
1709
- *
1710
- * **What it does NOT check:**
1711
- * - Missing properties: Keys in shape that don't exist in formValue
1712
- * (forms build incrementally with `NgxDeepPartial`, and `@if` conditionally renders fields)
1713
- *
1714
- * Only runs in development mode.
1715
- *
1716
- * @param formVal - The current form value
1717
- * @param shape - The expected shape (created with `NgxDeepRequired<T>`)
1718
- */
1719
- function validateShape(formVal, shape) {
1720
- if (isDevMode()) {
1721
- validateFormValueAgainstShape(formVal, shape);
1722
- }
1723
- }
1724
- /**
1725
- * Recursively validates form value keys against the shape.
1726
- * Reports warnings for extra properties and type mismatches.
1727
- */
1728
- function validateFormValueAgainstShape(formValue, shape, path = '') {
1729
- for (const key of Object.keys(formValue)) {
1730
- const value = formValue[key];
1731
- const fieldPath = path ? `${path}.${key}` : key;
1732
- // Skip null/undefined values (valid during form initialization)
1733
- if (value == null) {
1734
- continue;
1735
- }
1736
- // For array items (numeric keys > 0), compare against the first item in shape
1737
- // since we only define one example item in the shape for arrays
1738
- const isNumericKey = !isNaN(parseFloat(key));
1739
- const shapeKey = isNumericKey && parseFloat(key) > 0 ? '0' : key;
1740
- const shapeValue = shape?.[shapeKey];
1741
- // Skip Date fields receiving empty strings (common in date picker libraries)
1742
- if (shapeValue instanceof Date && value === '') {
1743
- continue;
1744
- }
1745
- // Handle object values (recurse into nested objects)
1746
- if (typeof value === 'object') {
1747
- // Type mismatch: formValue has object, but shape expects primitive
1748
- if (!isNumericKey &&
1749
- (typeof shapeValue !== 'object' || shapeValue === null)) {
1750
- logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
1467
+ /**
1468
+ * Updates validation for all dependent fields.
1469
+ *
1470
+ * Handles:
1471
+ * - Touch state propagation (mark dependents touched when trigger is touched)
1472
+ * - Loop prevention via validationInProgress set
1473
+ * - Silent validation (emitEvent: false) to prevent feedback loops
1474
+ *
1475
+ * @param form - The NgForm instance
1476
+ * @param control - The trigger control
1477
+ * @param triggerField - Field path of the trigger
1478
+ * @param dependents - Array of dependent field paths to update
1479
+ */
1480
+ #updateDependentFields(form, control, triggerField, dependents) {
1481
+ // Mark trigger field as in-progress to prevent it from being re-triggered
1482
+ this.validationInProgress.add(triggerField);
1483
+ for (const depField of dependents) {
1484
+ const dependentControl = form.get(depField);
1485
+ if (!dependentControl) {
1486
+ continue;
1487
+ }
1488
+ // Only validate if not already in progress (prevents bidirectional loops)
1489
+ if (!this.validationInProgress.has(depField)) {
1490
+ // CRITICAL: Mark the dependent field as in-progress BEFORE calling updateValueAndValidity
1491
+ // This prevents the dependent field's valueChanges from triggering its own validationConfig
1492
+ this.validationInProgress.add(depField);
1493
+ // Propagate touch state BEFORE validation to avoid duplicate class updates
1494
+ // markAsTouched() triggers change detection, so we do it once before updateValueAndValidity
1495
+ if (control.touched && !dependentControl.touched) {
1496
+ dependentControl.markAsTouched({ onlySelf: true });
1497
+ }
1498
+ // emitEvent: true is REQUIRED for async validators to actually run
1499
+ // The validationInProgress Set prevents infinite loops:
1500
+ // 1. Field A changes → triggers validation on dependent field B
1501
+ // 2. B is added to validationInProgress Set
1502
+ // 3. B's statusChanges emits → #handleValueChange checks validationInProgress
1503
+ // 4. Since B is in validationInProgress, its validationConfig is not triggered
1504
+ // 5. After 500ms timeout, B is removed from validationInProgress
1505
+ // This way:
1506
+ // - Async validators CAN run (emitEvent: true)
1507
+ // - BUT circular triggers are prevented (validationInProgress check)
1508
+ dependentControl.updateValueAndValidity({
1509
+ onlySelf: true,
1510
+ emitEvent: true, // Changed from false - REQUIRED for validators to run!
1511
+ });
1512
+ // CRITICAL: Force immediate change detection for OnPush components
1513
+ // updateValueAndValidity updates the control's status, but doesn't automatically
1514
+ // trigger change detection. Components using OnPush won't see the ng-invalid class
1515
+ // update in the DOM without this. Using detectChanges() instead of markForCheck()
1516
+ // to force immediate synchronous update rather than waiting for next CD cycle.
1517
+ this.cdr.detectChanges();
1751
1518
  }
1752
- // Recurse into nested object
1753
- validateFormValueAgainstShape(value, shapeValue ?? {}, fieldPath);
1754
- continue;
1755
- }
1756
- // Extra property: key exists in formValue but not in shape (likely a typo)
1757
- if (!isNumericKey && shape && !(shapeKey in shape)) {
1758
- logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
1759
1519
  }
1520
+ // Keep fields marked as in-progress for a short time to prevent immediate re-triggering
1521
+ // Use setTimeout to ensure async validators have time to complete before allowing new triggers
1522
+ setTimeout(() => {
1523
+ this.validationInProgress.delete(triggerField);
1524
+ for (const depField of dependents) {
1525
+ this.validationInProgress.delete(depField);
1526
+ }
1527
+ }, VALIDATION_IN_PROGRESS_TIMEOUT_MS);
1760
1528
  }
1529
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1530
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormDirective, isStandalone: true, selector: "form[scVestForm], form[ngxVestForm]", inputs: { formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, formShape: { classPropertyName: "formShape", publicName: "formShape", isSignal: true, isRequired: false, transformFunction: null }, validationConfig: { classPropertyName: "validationConfig", publicName: "validationConfig", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formValueChange: "formValueChange", errorsChange: "errorsChange", dirtyChange: "dirtyChange", validChange: "validChange" }, exportAs: ["scVestForm", "ngxVestForm"], ngImport: i0 }); }
1761
1531
  }
1532
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormDirective, decorators: [{
1533
+ type: Directive,
1534
+ args: [{
1535
+ selector: 'form[scVestForm], form[ngxVestForm]',
1536
+ exportAs: 'scVestForm, ngxVestForm',
1537
+ }]
1538
+ }], ctorParameters: () => [], propDecorators: { formValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "formValue", required: false }] }], suite: [{ type: i0.Input, args: [{ isSignal: true, alias: "suite", required: false }] }], formShape: [{ type: i0.Input, args: [{ isSignal: true, alias: "formShape", required: false }] }], validationConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationConfig", required: false }] }], formValueChange: [{ type: i0.Output, args: ["formValueChange"] }], errorsChange: [{ type: i0.Output, args: ["errorsChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], validChange: [{ type: i0.Output, args: ["validChange"] }] } });
1762
1539
 
1763
- /**
1764
- * Main form directive for ngx-vest-forms that bridges Angular template-driven forms with Vest.js validation.
1765
- *
1766
- * This directive provides:
1767
- * - **Unidirectional data flow**: Use `[ngModel]` (not `[(ngModel)]`) with `(formValueChange)` for predictable state updates
1768
- * - **Vest.js integration**: Automatic async validators from Vest suites with field-level optimization
1769
- * - **Validation dependencies**: Configure cross-field validation triggers via `validationConfig`
1770
- * - **Form state**: Access validity, errors, and values through the `formState` signal
1771
- *
1772
- * @usageNotes
1773
- *
1774
- * ### Basic Usage
1775
- * ```html
1776
- * <form ngxVestForm [suite]="validationSuite" (formValueChange)="formValue.set($event)">
1777
- * <input name="email" [ngModel]="formValue().email" />
1778
- * </form>
1779
- * ```
1780
- *
1781
- * ### With Validation Dependencies
1782
- * ```html
1783
- * <form ngxVestForm [suite]="suite" [validationConfig]="validationConfig">
1784
- * <input name="password" [ngModel]="formValue().password" />
1785
- * <input name="confirmPassword" [ngModel]="formValue().confirmPassword" />
1786
- * </form>
1787
- * ```
1788
- * ```typescript
1789
- * validationConfig = { 'password': ['confirmPassword'] };
1790
- * ```
1791
- *
1792
- * ### Accessing Form State
1793
- * ```typescript
1794
- * vestForm = viewChild.required('vestForm', { read: FormDirective });
1795
- * isValid = computed(() => this.vestForm().formState().valid);
1796
- * ```
1797
- *
1798
- * @see {@link https://github.com/ngx-vest-forms/ngx-vest-forms} for full documentation
1799
- * @publicApi
1800
- */
1801
- class FormDirective {
1802
- // Track last linked value to prevent unnecessary updates
1803
- #lastLinkedValue;
1804
- #lastSyncedFormValue;
1805
- #lastSyncedModelValue;
1806
- // Internal signal tracking form value changes via statusChanges
1807
- #value;
1540
+ const INITIAL_FORM_CONTROL_STATE = {
1541
+ status: 'INVALID',
1542
+ isValid: false,
1543
+ isInvalid: true,
1544
+ isPending: false,
1545
+ isDisabled: false,
1546
+ isTouched: false,
1547
+ isDirty: false,
1548
+ isPristine: true,
1549
+ errors: null,
1550
+ };
1551
+ class FormControlStateDirective {
1552
+ #hostNgModel;
1553
+ #hostNgModelGroup;
1554
+ #injector;
1808
1555
  /**
1809
- * LinkedSignal that computes form values from Angular form state.
1810
- * This eliminates timing issues with the previous dual-effect pattern.
1556
+ * Reference to the parent FormDirective for accessing field warnings.
1557
+ * Optional to support usage outside of ngxVestForm context.
1811
1558
  */
1812
- #formValueSignal;
1559
+ #formDirective;
1560
+ /**
1561
+ * Computed signal for the active control (NgModel or NgModelGroup)
1562
+ */
1563
+ #activeControl;
1564
+ /**
1565
+ * Consolidated internal signal for interaction state tracking.
1566
+ * Combines touched, dirty, and hasBeenValidated into a single signal
1567
+ * to reduce signal overhead and simplify state updates.
1568
+ */
1569
+ #interactionState;
1570
+ /**
1571
+ * Track the previous status to detect actual status changes (not just status emissions).
1572
+ * This helps distinguish between initial control creation and actual re-validation.
1573
+ */
1574
+ #previousStatus;
1575
+ /**
1576
+ * Internal signal for control state (updated reactively)
1577
+ */
1578
+ #controlStateSignal;
1579
+ constructor() {
1580
+ this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
1581
+ this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : []));
1582
+ this.#hostNgModel = inject(NgModel, { self: true, optional: true });
1583
+ this.#hostNgModelGroup = inject(NgModelGroup, {
1584
+ self: true,
1585
+ optional: true,
1586
+ });
1587
+ this.#injector = inject(Injector);
1588
+ /**
1589
+ * Reference to the parent FormDirective for accessing field warnings.
1590
+ * Optional to support usage outside of ngxVestForm context.
1591
+ */
1592
+ this.#formDirective = inject(FormDirective, { optional: true });
1593
+ /**
1594
+ * Computed signal for the active control (NgModel or NgModelGroup)
1595
+ */
1596
+ this.#activeControl = computed(() => this.#hostNgModel ||
1597
+ this.#hostNgModelGroup ||
1598
+ this.contentNgModel() ||
1599
+ this.contentNgModelGroup() ||
1600
+ null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
1601
+ /**
1602
+ * Consolidated internal signal for interaction state tracking.
1603
+ * Combines touched, dirty, and hasBeenValidated into a single signal
1604
+ * to reduce signal overhead and simplify state updates.
1605
+ */
1606
+ this.#interactionState = signal({
1607
+ isTouched: false,
1608
+ isDirty: false,
1609
+ hasBeenValidated: false,
1610
+ }, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
1611
+ /**
1612
+ * Track the previous status to detect actual status changes (not just status emissions).
1613
+ * This helps distinguish between initial control creation and actual re-validation.
1614
+ */
1615
+ this.#previousStatus = null;
1616
+ /**
1617
+ * Internal signal for control state (updated reactively)
1618
+ */
1619
+ this.#controlStateSignal = signal(INITIAL_FORM_CONTROL_STATE, ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : []));
1620
+ /**
1621
+ * Main control state computed signal (merges robust touched/dirty)
1622
+ */
1623
+ this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
1624
+ /**
1625
+ * Extracts error messages from Angular/Vest errors (recursively flattens)
1626
+ */
1627
+ this.errorMessages = computed(() => {
1628
+ const { errors } = this.controlState();
1629
+ if (!errors)
1630
+ return [];
1631
+ // Vest errors are stored in the 'errors' property as an array
1632
+ const vestErrors = errors.errors;
1633
+ if (vestErrors) {
1634
+ return vestErrors
1635
+ .map((error) => normalizeErrorMessage(error))
1636
+ .filter((error) => Boolean(error));
1637
+ }
1638
+ // Fallback to flattened Angular error keys, excluding 'warnings' key
1639
+ // to prevent warnings from appearing in the error list
1640
+ const errorsWithoutWarnings = { ...errors };
1641
+ delete errorsWithoutWarnings['warnings'];
1642
+ return this.#flattenAngularErrors(errorsWithoutWarnings);
1643
+ }, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
1644
+ /**
1645
+ * ADVANCED: updateOn strategy (change/blur/submit) if available
1646
+ */
1647
+ this.updateOn = computed(() => {
1648
+ const ngModel = this.contentNgModel() || this.#hostNgModel;
1649
+ return ngModel?.options?.updateOn ?? 'change';
1650
+ }, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
1651
+ /**
1652
+ * ADVANCED: Composite/derived signals for advanced error display logic
1653
+ */
1654
+ this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
1655
+ this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
1656
+ this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
1657
+ /**
1658
+ * Extracts warning messages from Vest validation results.
1659
+ * Checks two sources:
1660
+ * 1. control.errors.warnings (when errors exist alongside warnings)
1661
+ * 2. FormDirective.fieldWarnings (for warnings-only scenarios)
1662
+ * This dual-source approach allows warnings to be displayed without affecting field validity.
1663
+ */
1664
+ this.warningMessages = computed(() => {
1665
+ // Source 1: warnings from control.errors (when field has errors)
1666
+ const { errors } = this.controlState();
1667
+ // Always read fieldWarnings signal to ensure reactive tracking
1668
+ // This must be read unconditionally for proper signal dependency tracking
1669
+ const fieldWarnings = this.#formDirective?.fieldWarnings();
1670
+ const activeControl = this.#activeControl();
1671
+ // If we have warnings in control.errors, use those (errors+warnings case)
1672
+ if (errors?.warnings) {
1673
+ return [...errors.warnings];
1674
+ }
1675
+ // Source 2: warnings from FormDirective (for warnings-only scenarios)
1676
+ // When a field only has warnings (no errors), they're stored in fieldWarnings
1677
+ // to keep the field valid while still allowing warnings to be displayed.
1678
+ if (fieldWarnings && activeControl) {
1679
+ // Get the field path from the control's path property
1680
+ // NgModel.path returns an array like ['passwords', 'password'] which needs to be joined
1681
+ const path = activeControl.path;
1682
+ if (path?.length) {
1683
+ const fieldPath = path.join('.');
1684
+ const warnings = fieldWarnings.get(fieldPath);
1685
+ if (warnings) {
1686
+ return [...warnings];
1687
+ }
1688
+ }
1689
+ }
1690
+ return [];
1691
+ }, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
1692
+ /**
1693
+ * Whether async validation is in progress
1694
+ */
1695
+ this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
1696
+ /**
1697
+ * Convenience signals for common state checks
1698
+ */
1699
+ this.isValid = computed(() => this.controlState().isValid, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
1700
+ this.isInvalid = computed(() => this.controlState().isInvalid, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
1701
+ this.isPending = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
1702
+ this.isTouched = computed(() => this.controlState().isTouched, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
1703
+ this.isDirty = computed(() => this.controlState().isDirty, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
1704
+ this.isPristine = computed(() => this.controlState().isPristine, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
1705
+ this.isDisabled = computed(() => this.controlState().isDisabled, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
1706
+ this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : []));
1707
+ /**
1708
+ * Whether this control has been validated at least once.
1709
+ * True after the first validation completes, even if the user hasn't touched the field.
1710
+ * This enables showing errors for validationConfig-triggered validations.
1711
+ */
1712
+ this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : []));
1713
+ // Update control state reactively with proper cleanup
1714
+ effect((onCleanup) => {
1715
+ const control = this.#activeControl();
1716
+ const interaction = this.#interactionState();
1717
+ if (!control) {
1718
+ this.#controlStateSignal.set(INITIAL_FORM_CONTROL_STATE);
1719
+ return;
1720
+ }
1721
+ // Listen to control changes
1722
+ const sub = control.control?.statusChanges?.subscribe(() => {
1723
+ const { status, valid, invalid, pending, disabled, pristine, errors, touched, dirty, } = control;
1724
+ const currentStatus = status;
1725
+ // Mark as validated when any of the following conditions are met:
1726
+ // 1. The control has been touched (user blurred the field).
1727
+ // 2. The control's status has actually changed (not the first status emission),
1728
+ // AND the new status is not 'PENDING' (validation completed),
1729
+ // AND the control has been interacted with (dirty).
1730
+ //
1731
+ // This ensures hasBeenValidated is true for:
1732
+ // - User blur events (touched becomes true)
1733
+ // - User-triggered validations (dirty)
1734
+ // - ValidationConfig-triggered validations that result in the control becoming touched
1735
+ // But NOT for initial page load validations.
1736
+ //
1737
+ // Accessibility: The logic is structured for clarity and maintainability.
1738
+ // IMPORTANT: Read touched/dirty directly from control, not from signal,
1739
+ // to avoid race conditions with afterEveryRender sync.
1740
+ if (touched || // Control was blurred (most common case)
1741
+ (this.#previousStatus !== null && // Not the first status emission
1742
+ this.#previousStatus !== currentStatus && // Status actually changed
1743
+ currentStatus !== null &&
1744
+ currentStatus !== 'PENDING' &&
1745
+ dirty) // Or control value changed (typed)
1746
+ ) {
1747
+ this.#interactionState.update((state) => ({
1748
+ ...state,
1749
+ hasBeenValidated: true,
1750
+ }));
1751
+ }
1752
+ // Track current status for next iteration
1753
+ this.#previousStatus = currentStatus;
1754
+ this.#controlStateSignal.set({
1755
+ status: currentStatus,
1756
+ isValid: valid ?? false,
1757
+ isInvalid: invalid ?? false,
1758
+ isPending: pending ?? false,
1759
+ isDisabled: disabled ?? false,
1760
+ isTouched: interaction.isTouched,
1761
+ isDirty: interaction.isDirty,
1762
+ isPristine: pristine ?? true,
1763
+ errors: errors,
1764
+ });
1765
+ });
1766
+ // Initial update
1767
+ const { status, valid, invalid, pending, disabled, pristine, errors } = control;
1768
+ const initialStatus = status;
1769
+ this.#previousStatus = initialStatus;
1770
+ this.#controlStateSignal.set({
1771
+ status: initialStatus,
1772
+ isValid: valid ?? false,
1773
+ isInvalid: invalid ?? false,
1774
+ isPending: pending ?? false,
1775
+ isDisabled: disabled ?? false,
1776
+ isTouched: interaction.isTouched,
1777
+ isDirty: interaction.isDirty,
1778
+ isPristine: pristine ?? true,
1779
+ errors: errors,
1780
+ });
1781
+ // Proper cleanup using onCleanup callback (Angular 21 best practice)
1782
+ onCleanup(() => sub?.unsubscribe());
1783
+ });
1784
+ // Robustly sync touched/dirty/pending after every render (Angular 18+ best practice)
1785
+ // This handles cases where statusChanges events are missed or delayed
1786
+ afterEveryRender(() => {
1787
+ const control = this.#activeControl();
1788
+ if (control) {
1789
+ const currentInteraction = this.#interactionState();
1790
+ const currentState = this.#controlStateSignal();
1791
+ const newTouched = control.touched ?? false;
1792
+ const newDirty = control.dirty ?? false;
1793
+ // Sync interaction state (touched/dirty)
1794
+ if (newTouched !== currentInteraction.isTouched ||
1795
+ newDirty !== currentInteraction.isDirty) {
1796
+ // Mark as validated when control becomes touched (e.g., user blurred the field)
1797
+ // This handles the case where blur doesn't trigger statusChanges (field already invalid)
1798
+ const shouldMarkValidated = newTouched && !currentInteraction.isTouched;
1799
+ this.#interactionState.update((state) => ({
1800
+ ...state,
1801
+ isTouched: newTouched,
1802
+ isDirty: newDirty,
1803
+ hasBeenValidated: shouldMarkValidated
1804
+ ? true
1805
+ : state.hasBeenValidated,
1806
+ }));
1807
+ }
1808
+ // Sync pending state only when it transitions from true to false
1809
+ // This fixes "Validating..." being stuck when statusChanges misses the transition
1810
+ // We only sync when pending goes false to avoid interfering with async validation in progress
1811
+ const controlPending = control.pending ?? false;
1812
+ if (currentState.isPending && !controlPending) {
1813
+ // Pending changed from true to false - update the full state
1814
+ const newStatus = control.status;
1815
+ this.#controlStateSignal.set({
1816
+ status: newStatus,
1817
+ isPending: false,
1818
+ isValid: control.valid ?? false,
1819
+ isInvalid: control.invalid ?? false,
1820
+ isPristine: control.pristine ?? true,
1821
+ isDisabled: control.disabled ?? false,
1822
+ isTouched: newTouched,
1823
+ isDirty: newDirty,
1824
+ errors: control.errors,
1825
+ });
1826
+ }
1827
+ }
1828
+ }, { injector: this.#injector });
1829
+ }
1830
+ /**
1831
+ * Recursively flattens Angular error objects into an array of error messages.
1832
+ * Handles string values, objects with a message property, and nested structures.
1833
+ */
1834
+ #flattenAngularErrors(errors) {
1835
+ const result = [];
1836
+ for (const key of Object.keys(errors)) {
1837
+ const value = errors[key];
1838
+ if (typeof value === 'string') {
1839
+ // String values: push the value itself, not the key
1840
+ result.push(value);
1841
+ }
1842
+ else if (isErrorWithMessage(value)) {
1843
+ // Objects with a 'message' property: extract the message
1844
+ result.push(value.message);
1845
+ }
1846
+ else if (typeof value === 'object' && value !== null) {
1847
+ // Nested objects/arrays: recursively flatten
1848
+ result.push(...this.#flattenAngularErrors(value));
1849
+ }
1850
+ else {
1851
+ // Fallback: push the key for primitive types (backward compatibility)
1852
+ result.push(key);
1853
+ }
1854
+ }
1855
+ return result;
1856
+ }
1857
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1858
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.1", type: FormControlStateDirective, isStandalone: true, selector: "[formControlState], [ngxControlState]", queries: [{ propertyName: "contentNgModel", first: true, predicate: NgModel, descendants: true, isSignal: true }, { propertyName: "contentNgModelGroup", first: true, predicate: NgModelGroup, descendants: true, isSignal: true }], exportAs: ["formControlState", "ngxControlState"], ngImport: i0 }); }
1859
+ }
1860
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormControlStateDirective, decorators: [{
1861
+ type: Directive,
1862
+ args: [{
1863
+ selector: '[formControlState], [ngxControlState]',
1864
+ exportAs: 'formControlState, ngxControlState',
1865
+ }]
1866
+ }], ctorParameters: () => [], propDecorators: { contentNgModel: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModel), { isSignal: true }] }], contentNgModelGroup: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModelGroup), { isSignal: true }] }] } });
1867
+ /**
1868
+ * Type guard for objects with a message property
1869
+ */
1870
+ function isErrorWithMessage(value) {
1871
+ return (typeof value === 'object' &&
1872
+ value !== null &&
1873
+ 'message' in value &&
1874
+ typeof value.message === 'string');
1875
+ }
1876
+ function normalizeErrorMessage(value) {
1877
+ if (typeof value === 'string') {
1878
+ return value;
1879
+ }
1880
+ if (isErrorWithMessage(value)) {
1881
+ return value.message;
1882
+ }
1883
+ if (value === null || value === undefined) {
1884
+ return null;
1885
+ }
1886
+ if (typeof value === 'object') {
1887
+ try {
1888
+ return JSON.stringify(value);
1889
+ }
1890
+ catch {
1891
+ return String(value);
1892
+ }
1893
+ }
1894
+ return String(value);
1895
+ }
1896
+
1897
+ const SC_ERROR_DISPLAY_MODE_DEFAULT = 'on-blur-or-submit';
1898
+ const SC_WARNING_DISPLAY_MODE_DEFAULT = 'on-validated-or-touch';
1899
+ class FormErrorDisplayDirective {
1900
+ #formControlState;
1901
+ // Optionally inject NgForm for form submission tracking
1902
+ #ngForm;
1813
1903
  /**
1814
- * Track the Angular form status as a signal for advanced status flags
1904
+ * Internal trigger signal that updates whenever form submit or status changes.
1905
+ * Used to ensure reactive tracking for the formSubmitted computed signal.
1815
1906
  */
1816
- #statusSignal;
1907
+ #formEventTrigger;
1817
1908
  constructor() {
1818
- this.ngForm = inject(NgForm, { self: true });
1819
- this.destroyRef = inject(DestroyRef);
1820
- this.cdr = inject(ChangeDetectorRef);
1821
- this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
1822
- // Track last linked value to prevent unnecessary updates
1823
- this.#lastLinkedValue = null;
1824
- this.#lastSyncedFormValue = null;
1825
- this.#lastSyncedModelValue = null;
1826
- // Internal signal tracking form value changes via statusChanges
1827
- this.#value = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
1828
- /**
1829
- * LinkedSignal that computes form values from Angular form state.
1830
- * This eliminates timing issues with the previous dual-effect pattern.
1831
- */
1832
- this.#formValueSignal = linkedSignal(() => {
1833
- // Track form value changes
1834
- this.#value();
1835
- const raw = mergeValuesAndRawValues(this.ngForm.form);
1836
- if (Object.keys(this.ngForm.form.controls).length > 0) {
1837
- this.#lastLinkedValue = raw;
1838
- return raw;
1839
- }
1840
- else if (this.#lastLinkedValue !== null) {
1841
- return this.#lastLinkedValue;
1842
- }
1843
- return null;
1844
- }, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : []));
1845
- /**
1846
- * Track the Angular form status as a signal for advanced status flags
1847
- */
1848
- this.#statusSignal = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
1909
+ this.#formControlState = inject(FormControlStateDirective);
1910
+ // Optionally inject NgForm for form submission tracking
1911
+ this.#ngForm = inject(NgForm, { optional: true });
1849
1912
  /**
1850
- * Computed signal for form state with validity and errors.
1851
- * Used by templates and tests as vestForm.formState().valid/errors
1852
- *
1853
- * Uses custom equality function to prevent unnecessary recalculations
1854
- * when form status changes but actual values/errors remain the same.
1913
+ * Input signal for error display mode.
1914
+ * Works seamlessly with hostDirectives in Angular 19+.
1855
1915
  */
1856
- this.formState = computed(() => {
1857
- // Tie to status signal to ensure recomputation on validation changes
1858
- this.#statusSignal();
1859
- return {
1860
- valid: this.ngForm.form.valid,
1861
- errors: getAllFormErrors(this.ngForm.form),
1862
- value: this.#formValueSignal(),
1863
- };
1864
- }, { ...(ngDevMode ? { debugName: "formState" } : {}), equal: (a, b) => {
1865
- // Fast path: reference equality
1866
- if (a === b)
1867
- return true;
1868
- // Null/undefined check
1869
- if (!a || !b)
1870
- return false;
1871
- // Deep equality check for form state properties
1872
- return (a.valid === b.valid &&
1873
- fastDeepEqual(a.errors, b.errors) &&
1874
- fastDeepEqual(a.value, b.value));
1875
- } });
1916
+ this.errorDisplayMode = input(inject(NGX_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
1917
+ inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
1918
+ SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
1876
1919
  /**
1877
- * The value of the form, this is needed for the validation part.
1878
- * Using input() here because two-way binding is provided via formValueChange output.
1879
- * In the minimal core directive (form-core.directive.ts), this would be model() instead.
1920
+ * Input signal for warning display mode.
1921
+ * Controls whether warnings are shown only after touch or also after validation.
1880
1922
  */
1881
- this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
1923
+ this.warningDisplayMode = input(inject(NGX_WARNING_DISPLAY_MODE_TOKEN, { optional: true }) ??
1924
+ SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : []));
1925
+ // Expose state signals from FormControlStateDirective
1926
+ this.controlState = this.#formControlState.controlState;
1927
+ this.errorMessages = this.#formControlState.errorMessages;
1928
+ this.warningMessages = this.#formControlState.warningMessages;
1929
+ this.hasPendingValidation = this.#formControlState.hasPendingValidation;
1930
+ this.isTouched = this.#formControlState.isTouched;
1931
+ this.isDirty = this.#formControlState.isDirty;
1932
+ this.isValid = this.#formControlState.isValid;
1933
+ this.isInvalid = this.#formControlState.isInvalid;
1934
+ this.hasBeenValidated = this.#formControlState.hasBeenValidated;
1882
1935
  /**
1883
- * Static vest suite that will be used to feed our angular validators.
1884
- * Accepts both NgxVestSuite and NgxTypedVestSuite through compatible type signatures.
1885
- * NgxTypedVestSuite<T> is assignable to NgxVestSuite<T> due to bivariance and
1886
- * FormFieldName<T> (string literal union) being assignable to string.
1936
+ * Expose updateOn and formSubmitted as public signals for advanced consumers.
1937
+ * updateOn: The ngModelOptions.updateOn value for the control (change/blur/submit)
1938
+ * formSubmitted: true after the form is submitted (if NgForm is present)
1887
1939
  */
1888
- this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
1940
+ this.updateOn = this.#formControlState.updateOn;
1889
1941
  /**
1890
- * The shape of our form model. This is a deep required version of the form model
1891
- * The goal is to add default values to the shape so when the template-driven form
1892
- * contains values that shouldn't be there (typo's) that the developer gets run-time
1893
- * errors in dev mode
1942
+ * Internal trigger signal that updates whenever form submit or status changes.
1943
+ * Used to ensure reactive tracking for the formSubmitted computed signal.
1894
1944
  */
1895
- this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : []));
1945
+ this.#formEventTrigger = this.#ngForm
1946
+ ? toSignal(merge(this.#ngForm.ngSubmit, this.#ngForm.statusChanges ?? of()).pipe(startWith(null)), { initialValue: null })
1947
+ : signal(null);
1896
1948
  /**
1897
- * Updates the validation config which is a dynamic object that will be used to
1898
- * trigger validations on the dependant fields
1899
- * Eg: ```typescript
1900
- * validationConfig = {
1901
- * 'passwords.password': ['passwords.confirmPassword']
1902
- * }
1903
- * ```
1904
- *
1905
- * This will trigger the updateValueAndValidity on passwords.confirmPassword every time the passwords.password gets a new value
1949
+ * Signal that tracks NgForm.submitted state reactively.
1906
1950
  *
1907
- * @param v
1908
- */
1909
- this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : []));
1910
- this.pending$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v === 'PENDING'), distinctUntilChanged());
1911
- /**
1912
- * Emits every time the form status changes in a state
1913
- * that is not PENDING
1914
- * We need this to assure that the form is in 'idle' state
1915
- */
1916
- this.idle$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING'), distinctUntilChanged());
1917
- /**
1918
- * Triggered as soon as the form value changes
1919
- * It also contains the disabled values (raw values)
1951
+ * Uses a trigger signal pattern for cleaner reactive tracking:
1952
+ * - ngSubmit: fires when form is submitted (sets NgForm.submitted = true)
1953
+ * - statusChanges: fires after resetForm() (which sets NgForm.submitted = false)
1920
1954
  *
1921
- * Cleanup is handled automatically by the directive when it's destroyed.
1955
+ * This ensures proper sync with both submit and reset operations.
1922
1956
  */
1923
- this.formValueChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof ValueChangeEvent), map((v) => v.value), distinctUntilChanged((prev, curr) => {
1924
- // Use efficient deep equality instead of JSON.stringify for better performance
1925
- return fastDeepEqual(prev, curr);
1926
- }), map(() => mergeValuesAndRawValues(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
1957
+ this.formSubmitted = computed(() => {
1958
+ // Trigger signal ensures this recomputes on submit/status changes
1959
+ this.#formEventTrigger();
1960
+ return this.#ngForm?.submitted ?? false;
1961
+ }, ...(ngDevMode ? [{ debugName: "formSubmitted" }] : []));
1927
1962
  /**
1928
- * Emits an object with all the errors of the form
1929
- * every time a form control or form groups changes its status to valid or invalid
1963
+ * Determines if errors should be shown based on the specified display mode
1964
+ * and the control's state (touched/submitted/validated).
1930
1965
  *
1931
- * Cleanup is handled automatically by the directive when it's destroyed.
1932
- */
1933
- this.errorsChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING'), map(() => getAllFormErrors(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
1934
- /**
1935
- * Triggered as soon as the form becomes dirty
1966
+ * Note: We check both hasErrors (extracted error messages) AND isInvalid (Angular's validation state)
1967
+ * because in some cases (like conditional validations via validationConfig), the control is marked
1968
+ * as invalid by Angular before error messages are extracted from Vest. This ensures aria-invalid
1969
+ * is set correctly even during the validation propagation delay.
1936
1970
  *
1937
- * Cleanup is handled automatically by the directive when it's destroyed.
1971
+ * For validationConfig-triggered validations: A field can be validated without being touched
1972
+ * (e.g., confirmPassword validated when password changes). We check hasBeenValidated to show
1973
+ * errors in these scenarios, providing better UX and proper ARIA attributes.
1938
1974
  */
1939
- this.dirtyChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof PristineChangeEvent), map((v) => !v.pristine), startWith(this.ngForm.form.dirty), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
1975
+ this.shouldShowErrors = computed(() => {
1976
+ const mode = this.errorDisplayMode();
1977
+ const isTouched = this.isTouched();
1978
+ const isDirty = this.isDirty();
1979
+ const isInvalid = this.isInvalid();
1980
+ const hasErrors = this.errorMessages().length > 0;
1981
+ const updateOn = this.updateOn();
1982
+ const formSubmitted = this.formSubmitted();
1983
+ // Consider errors present if either we have error messages OR the control is invalid
1984
+ // This handles the race condition where Angular marks control invalid before Vest errors propagate
1985
+ const hasErrorState = hasErrors || isInvalid;
1986
+ // Always only show errors after submit if updateOn is 'submit'
1987
+ if (updateOn === 'submit') {
1988
+ return !!(formSubmitted && hasErrorState);
1989
+ }
1990
+ // Handle the new display modes
1991
+ switch (mode) {
1992
+ case 'always':
1993
+ // Always show errors immediately, even on pristine fields
1994
+ return hasErrorState;
1995
+ case 'on-dirty':
1996
+ // Show when value has changed, OR when touched/submitted (for backwards compat)
1997
+ return !!(isDirty || isTouched || formSubmitted) && hasErrorState;
1998
+ case 'on-blur':
1999
+ // Show after touch (blur) or form submission (traditional behavior, not dirty-based)
2000
+ return !!(isTouched || formSubmitted) && hasErrorState;
2001
+ case 'on-submit':
2002
+ // Show only after form submission
2003
+ return !!(formSubmitted && hasErrorState);
2004
+ case 'on-blur-or-submit':
2005
+ default:
2006
+ // Show after blur (touch) OR submit (default behavior)
2007
+ return !!((isTouched || formSubmitted) && hasErrorState);
2008
+ }
2009
+ }, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
1940
2010
  /**
1941
- * Fired when the status of the root form changes.
2011
+ * Errors to display (filtered for pending state)
1942
2012
  */
1943
- this.statusChanges$ = this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status), distinctUntilChanged());
2013
+ this.errors = computed(() => {
2014
+ if (this.hasPendingValidation())
2015
+ return [];
2016
+ return this.errorMessages();
2017
+ }, ...(ngDevMode ? [{ debugName: "errors" }] : []));
1944
2018
  /**
1945
- * Triggered When the form becomes valid but waits until the form is idle
1946
- *
1947
- * Cleanup is handled automatically by the directive when it's destroyed.
2019
+ * Warnings to display (filtered for pending state)
1948
2020
  */
1949
- this.validChange = outputFromObservable(this.statusChanges$.pipe(filter((e) => e === 'VALID' || e === 'INVALID'), map((v) => v === 'VALID'), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
2021
+ this.warnings = computed(() => {
2022
+ if (this.hasPendingValidation())
2023
+ return [];
2024
+ return this.warningMessages();
2025
+ }, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
1950
2026
  /**
1951
- * Track validation in progress to prevent circular triggering (Issue #19)
2027
+ * Whether the control is currently being validated (pending)
2028
+ * Excludes pristine+untouched controls to prevent "Validating..." on initial load
1952
2029
  */
1953
- this.validationInProgress = new Set();
2030
+ this.isPending = computed(() => {
2031
+ // Don't show pending state for pristine untouched controls
2032
+ // This prevents "Validating..." message appearing on initial page load
2033
+ const state = this.#formControlState.controlState();
2034
+ if (state.isPristine && !state.isTouched) {
2035
+ return false;
2036
+ }
2037
+ return this.hasPendingValidation();
2038
+ }, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
1954
2039
  /**
1955
- * Trigger shape validations if the form gets updated
1956
- * This is how we can throw run-time errors
2040
+ * Determines if warnings should be shown based on the specified display mode
2041
+ * and the control's state (touched/validated/dirty).
2042
+ *
2043
+ * NOTE: Unlike errors, warnings can exist on VALID fields (warnings-only scenario).
2044
+ * We don't require isInvalid() because Vest warn() tests don't affect field validity.
2045
+ *
2046
+ * UX Note: We include `hasBeenValidated` for `on-validated-or-touch` mode to support
2047
+ * cross-field validation. If Field A triggers validation on Field B (via validationConfig),
2048
+ * Field B should show warnings if it has them, even if the user hasn't touched Field B yet.
2049
+ * Unlike errors (which block submission), warnings are informational and safe to show.
1957
2050
  */
1958
- if (isDevMode()) {
1959
- effect(() => {
1960
- const v = this.formValue();
1961
- if (v && this.formShape()) {
1962
- validateShape(v, this.formShape());
2051
+ this.shouldShowWarnings = computed(() => {
2052
+ const mode = this.warningDisplayMode();
2053
+ const isTouched = this.isTouched();
2054
+ const isDirty = this.isDirty();
2055
+ const hasBeenValidated = this.hasBeenValidated();
2056
+ const hasWarnings = this.warningMessages().length > 0;
2057
+ const isPending = this.hasPendingValidation();
2058
+ const formSubmitted = this.formSubmitted();
2059
+ // No warnings to show or still pending
2060
+ if (!hasWarnings || isPending) {
2061
+ return false;
2062
+ }
2063
+ // Handle the warning display modes
2064
+ switch (mode) {
2065
+ case 'always':
2066
+ // Always show warnings immediately, even on pristine fields
2067
+ return true;
2068
+ case 'on-dirty':
2069
+ // Show when value has changed, OR when touched/submitted (for backwards compat)
2070
+ return isDirty || isTouched || formSubmitted;
2071
+ case 'on-touch':
2072
+ // Show after touch (blur) or form submission (traditional behavior, not dirty-based)
2073
+ return isTouched || formSubmitted;
2074
+ case 'on-validated-or-touch':
2075
+ default:
2076
+ // Show after validation runs or after touch/submit (default behavior)
2077
+ return hasBeenValidated || isTouched || formSubmitted;
2078
+ }
2079
+ }, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : []));
2080
+ // Warn about problematic combinations of updateOn and errorDisplayMode
2081
+ effect(() => {
2082
+ const mode = this.errorDisplayMode();
2083
+ const updateOn = this.updateOn();
2084
+ if (updateOn === 'submit' && mode === 'on-blur') {
2085
+ 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.');
2086
+ }
2087
+ });
2088
+ }
2089
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2090
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormErrorDisplayDirective, isStandalone: true, selector: "[formErrorDisplay], [ngxErrorDisplay]", inputs: { errorDisplayMode: { classPropertyName: "errorDisplayMode", publicName: "errorDisplayMode", isSignal: true, isRequired: false, transformFunction: null }, warningDisplayMode: { classPropertyName: "warningDisplayMode", publicName: "warningDisplayMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorDisplay", "ngxErrorDisplay"], hostDirectives: [{ directive: FormControlStateDirective }], ngImport: i0 }); }
2091
+ }
2092
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
2093
+ type: Directive,
2094
+ args: [{
2095
+ selector: '[formErrorDisplay], [ngxErrorDisplay]',
2096
+ exportAs: 'formErrorDisplay, ngxErrorDisplay',
2097
+ hostDirectives: [FormControlStateDirective],
2098
+ }]
2099
+ }], ctorParameters: () => [], propDecorators: { errorDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorDisplayMode", required: false }] }], warningDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "warningDisplayMode", required: false }] }] } });
2100
+
2101
+ /**
2102
+ * Creates a debounced pending state signal that prevents flashing validation messages.
2103
+ *
2104
+ * This utility helps implement a better UX for async validations by:
2105
+ * 1. Delaying the display of "Validating..." messages until validation takes longer than `showAfter`ms (default: 200ms)
2106
+ * 2. Keeping the message visible for at least `minimumDisplay`ms (default: 500ms) once shown to prevent flickering
2107
+ *
2108
+ * @example
2109
+ * ```typescript
2110
+ * @Component({
2111
+ * template: `
2112
+ * @if (showPendingMessage()) {
2113
+ * <div role="status" aria-live="polite">Validating…</div>
2114
+ * }
2115
+ * `
2116
+ * })
2117
+ * export class CustomControlWrapperComponent {
2118
+ * protected readonly errorDisplay = inject(FormErrorDisplayDirective, { self: true });
2119
+ *
2120
+ * // Create debounced pending state
2121
+ * private readonly pendingState = createDebouncedPendingState(
2122
+ * this.errorDisplay.isPending,
2123
+ * { showAfter: 200, minimumDisplay: 500 }
2124
+ * );
2125
+ *
2126
+ * protected readonly showPendingMessage = this.pendingState.showPendingMessage;
2127
+ * }
2128
+ * ```
2129
+ *
2130
+ * @param isPending - Signal indicating whether async validation is currently pending
2131
+ * @param options - Configuration options for debouncing behavior
2132
+ * @returns Object containing the debounced showPendingMessage signal and cleanup function
2133
+ */
2134
+ function createDebouncedPendingState(isPending, options = {}) {
2135
+ const { showAfter = 200, minimumDisplay = 500 } = options;
2136
+ // Create writable signal for debounced state
2137
+ const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : []));
2138
+ // Track timeouts
2139
+ let pendingTimeout = null;
2140
+ let minimumDisplayTimeout = null;
2141
+ // Cleanup function
2142
+ const cleanup = () => {
2143
+ if (pendingTimeout) {
2144
+ clearTimeout(pendingTimeout);
2145
+ pendingTimeout = null;
2146
+ }
2147
+ if (minimumDisplayTimeout) {
2148
+ clearTimeout(minimumDisplayTimeout);
2149
+ minimumDisplayTimeout = null;
2150
+ }
2151
+ };
2152
+ // Effect to manage debounced pending message display
2153
+ effect((onCleanup) => {
2154
+ const pending = isPending();
2155
+ if (pending) {
2156
+ // Clear any existing minimum display timeout
2157
+ if (minimumDisplayTimeout) {
2158
+ clearTimeout(minimumDisplayTimeout);
2159
+ minimumDisplayTimeout = null;
2160
+ }
2161
+ // Start delay timer before showing pending message
2162
+ pendingTimeout = setTimeout(() => {
2163
+ showPendingMessageSignal.set(true);
2164
+ pendingTimeout = null;
2165
+ }, showAfter);
2166
+ onCleanup(() => {
2167
+ if (pendingTimeout) {
2168
+ clearTimeout(pendingTimeout);
2169
+ pendingTimeout = null;
1963
2170
  }
1964
2171
  });
1965
2172
  }
1966
- /**
1967
- * Mark all the fields as touched when the form is submitted
1968
- */
1969
- this.ngForm.ngSubmit
1970
- .pipe(takeUntilDestroyed(this.destroyRef))
1971
- .subscribe(() => {
1972
- this.ngForm.form.markAllAsTouched();
1973
- });
1974
- /**
1975
- * Single bidirectional synchronization effect using linkedSignal.
1976
- * Uses proper deep comparison and change tracking for correct sync direction.
1977
- * Note: formValue is read-only input(), so we emit changes via formValueChange output.
1978
- */
1979
- effect(() => {
1980
- const formValue = this.#formValueSignal();
1981
- const modelValue = this.formValue();
1982
- // Skip if either is null
1983
- if (!formValue && !modelValue)
1984
- return;
1985
- // Compute change flags first
1986
- const formChanged = !fastDeepEqual(formValue, this.#lastSyncedFormValue);
1987
- const modelChanged = !fastDeepEqual(modelValue, this.#lastSyncedModelValue);
1988
- // Early return if nothing changed
1989
- if (!formChanged && !modelChanged) {
1990
- return;
1991
- }
1992
- if (formChanged && !modelChanged) {
1993
- // Form was modified by user -> form wins
1994
- // Note: We can't call this.formValue.set() since it's an input()
1995
- // The formValueChange output will emit the new value
1996
- // Use untracked() to avoid infinite loops - we're updating tracking state here
1997
- untracked(() => {
1998
- this.#lastSyncedFormValue = formValue;
1999
- this.#lastSyncedModelValue = formValue;
2000
- });
2173
+ else {
2174
+ // Validation completed
2175
+ if (pendingTimeout) {
2176
+ // Validation completed before delay - don't show message
2177
+ clearTimeout(pendingTimeout);
2178
+ pendingTimeout = null;
2001
2179
  }
2002
- else if (modelChanged && !formChanged) {
2003
- // Model was modified programmatically -> model wins
2004
- // Use untracked() to avoid infinite loops - we're updating tracking state here
2005
- untracked(() => {
2006
- // Update form controls with new model values
2007
- if (modelValue) {
2008
- // IMPORTANT: Use root patchValue instead of per-key setValue.
2009
- // - Supports nested objects (ngModelGroup) without throwing when partial objects are provided.
2010
- // - patchValue ignores missing controls/keys, which is compatible with DeepPartial form models.
2011
- // - emitEvent:false prevents feedback loops; validation still updates internally.
2012
- this.ngForm.form.patchValue(modelValue, { emitEvent: false });
2180
+ else if (showPendingMessageSignal()) {
2181
+ // Message was shown - keep it visible for minimum duration
2182
+ minimumDisplayTimeout = setTimeout(() => {
2183
+ showPendingMessageSignal.set(false);
2184
+ minimumDisplayTimeout = null;
2185
+ }, minimumDisplay);
2186
+ onCleanup(() => {
2187
+ if (minimumDisplayTimeout) {
2188
+ clearTimeout(minimumDisplayTimeout);
2189
+ minimumDisplayTimeout = null;
2013
2190
  }
2014
- this.#lastSyncedFormValue = modelValue;
2015
- this.#lastSyncedModelValue = modelValue;
2016
2191
  });
2017
2192
  }
2018
- else if (formChanged && modelChanged) {
2019
- // Both form and model changed simultaneously
2020
- // Check if they changed to the same value (synchronized change) or different values (conflict)
2021
- const valuesEqual = fastDeepEqual(formValue, modelValue);
2022
- if (valuesEqual) {
2023
- // Both changed to the same value - this is a synchronized change, not a conflict
2024
- // Just update tracking to acknowledge the change
2025
- untracked(() => {
2026
- this.#lastSyncedFormValue = formValue;
2027
- this.#lastSyncedModelValue = formValue;
2028
- });
2029
- }
2030
- else {
2031
- // Both changed to different values - this is a true conflict
2032
- // This is an edge case that should rarely happen in practice.
2033
- // We intentionally do nothing here to avoid breaking the Angular event flow.
2034
- // The form will continue with its current values, and validation will run normally.
2035
- // The next change (either form or model) will trigger proper synchronization.
2036
- }
2037
- }
2038
- });
2039
- // Set up validation config reactively
2040
- this.#setupValidationConfig();
2041
- }
2042
- /**
2043
- * Manually trigger form validation update.
2044
- *
2045
- * This is useful when form structure changes but no control values change,
2046
- * which means validation state might be stale. This method forces a re-evaluation
2047
- * of all form validators and updates the form validity state.
2048
- *
2049
- * **IMPORTANT: This method validates ALL form fields by design.**
2050
- * This is intentional for structure changes as conditional validators may now
2051
- * apply to different fields, requiring a complete validation refresh.
2052
- *
2053
- * **CRITICAL: This method does NOT mark fields as touched or show errors.**
2054
- * It only re-runs validation logic. To show all errors (e.g., on submit),
2055
- * use `markAllAsTouched()` instead or in combination.
2056
- *
2057
- * **When to use each:**
2058
- * - `triggerFormValidation()` - Re-run validation when structure changes
2059
- * - `markAllAsTouched()` - Show all errors to user (e.g., on submit)
2060
- * - Both together - Rare, only if structure changed AND you want to show errors
2061
- *
2062
- * **Note on form submission:**
2063
- * When using the default error display mode (`on-blur-or-submit`), you typically
2064
- * don't need to call this method on submit. The form directive automatically marks
2065
- * all fields as touched on `ngSubmit`, and errors will display automatically.
2066
- * Only use this method when form structure changes without value changes.
2067
- *
2068
- * **Use Cases:**
2069
- * - Conditionally showing/hiding form controls based on other field values
2070
- * - Adding or removing form controls dynamically
2071
- * - Switching between different form layouts where validation requirements change
2072
- * - Any scenario where form structure changes but no ValueChangeEvent is triggered
2073
- *
2074
- * **Example:**
2075
- * When switching from a form with required input fields to one with only informational content,
2076
- * the form should become valid, but this won't happen automatically
2077
- * when no value changes occur (e.g., switching from input fields to informational content).
2078
- *
2079
- * **Performance Note:**
2080
- * This method calls `updateValueAndValidity({ emitEvent: true })` on the root form,
2081
- * which validates all form controls. For large forms, consider if more granular
2082
- * validation updates are possible.
2083
- *
2084
- * @example
2085
- * ```typescript
2086
- * /// After changing form structure
2087
- * onProcedureTypeChange(newType: string) {
2088
- * this.procedureType.set(newType);
2089
- * /// Structure changed but no control values changed
2090
- * this.formDirective.triggerFormValidation();
2091
- * }
2092
- *
2093
- * /// For submit with multiple forms (show all errors)
2094
- * submitAll() {
2095
- * // Mark all as touched to show errors
2096
- * this.form1Ref().markAllAsTouched();
2097
- * this.form2Ref().markAllAsTouched();
2098
- * // Only needed if structure changed without value changes
2099
- * // this.form1Ref().triggerFormValidation();
2100
- * // this.form2Ref().triggerFormValidation();
2101
- * }
2102
- * ```
2103
- */
2104
- triggerFormValidation(path) {
2105
- if (path) {
2106
- const control = this.ngForm.form.get(path);
2107
- if (control) {
2108
- control.updateValueAndValidity({ emitEvent: true });
2109
- }
2110
- else if (isDevMode()) {
2111
- logWarning(NGX_VEST_FORMS_ERRORS.CONTROL_NOT_FOUND, path);
2193
+ }
2194
+ });
2195
+ return {
2196
+ showPendingMessage: showPendingMessageSignal.asReadonly(),
2197
+ cleanup,
2198
+ };
2199
+ }
2200
+
2201
+ // Counter for unique IDs
2202
+ let nextUniqueId$2 = 0;
2203
+ /**
2204
+ * Accessible form control wrapper with WCAG 2.2 AA compliance.
2205
+ *
2206
+ * Wrap form fields to automatically display validation errors, warnings, and pending states
2207
+ * with proper accessibility attributes.
2208
+ *
2209
+ * @usageNotes
2210
+ *
2211
+ * ### Basic Usage
2212
+ * ```html
2213
+ * <ngx-control-wrapper>
2214
+ * <label for="email">Email</label>
2215
+ * <input id="email" name="email" [ngModel]="formValue().email" />
2216
+ * </ngx-control-wrapper>
2217
+ * ```
2218
+ *
2219
+ * ### Error Display Modes
2220
+ * Control when errors appear using the `errorDisplayMode` input:
2221
+ * - `'on-blur-or-submit'` (default): Show errors after blur OR form submit
2222
+ * - `'on-blur'`: Show errors only after blur
2223
+ * - `'on-submit'`: Show errors only after form submit
2224
+ * - `'on-dirty'`: Show errors as soon as the field value changes
2225
+ * - `'always'`: Show errors immediately, even on pristine fields
2226
+ *
2227
+ * ```html
2228
+ * <ngx-control-wrapper [errorDisplayMode]="'on-submit'">
2229
+ * <input name="email" [ngModel]="formValue().email" />
2230
+ * </ngx-control-wrapper>
2231
+ * ```
2232
+ *
2233
+ * ### Warning Display Modes
2234
+ * Control when warnings appear using the `warningDisplayMode` input:
2235
+ * - `'on-validated-or-touch'` (default): Show warnings after validation runs or touch
2236
+ * - `'on-touch'`: Show warnings only after touch/blur
2237
+ * - `'on-dirty'`: Show warnings as soon as the field value changes
2238
+ * - `'always'`: Show warnings immediately, even on pristine fields
2239
+ *
2240
+ * ```html
2241
+ * <ngx-control-wrapper [warningDisplayMode]="'on-dirty'">
2242
+ * <input name="email" [ngModel]="formValue().email" />
2243
+ * </ngx-control-wrapper>
2244
+ * ```
2245
+ *
2246
+ * ### Accessibility Features (Automatic)
2247
+ * - Unique IDs for error/warning/pending regions
2248
+ * - `aria-describedby` linking errors to form controls
2249
+ * - `aria-invalid="true"` when errors are shown
2250
+ * - Uses `role="status"` with `aria-live="polite"` for non-disruptive announcements
2251
+ * - Debounced pending state to prevent flashing for quick validations
2252
+ *
2253
+ * ### WCAG 2.2 AA - Error Severity Levels
2254
+ * This component uses `role="status"` for **field-level** validation messages:
2255
+ * - **Errors**: Non-disruptive announcement (user can continue filling other fields)
2256
+ * - **Warnings**: Informational only, doesn't block submission
2257
+ * - **Pending**: Status update while async validation runs
2258
+ *
2259
+ * For **form-level blocking errors** (e.g., submission failed), implement a separate
2260
+ * error summary component with `role="alert"` and `aria-live="assertive"`.
2261
+ *
2262
+ * @see {@link FormErrorDisplayDirective} for custom wrapper implementation
2263
+ *
2264
+ * Form-Level Blocking Errors:
2265
+ * - For post-submit validation errors that block submission, implement a separate
2266
+ * form-level error summary with role="alert" and aria-live="assertive"
2267
+ * - This component uses role="status" for field-level errors (non-disruptive)
2268
+ * - Example for form-level errors:
2269
+ * ```html
2270
+ * <!-- Keep in DOM; update text content on submit -->
2271
+ * <div id="form-errors" role="alert" aria-live="assertive" aria-atomic="true"></div>
2272
+ * ```
2273
+ * - This separation provides immediate announcements for blocking form errors while
2274
+ * keeping inline field errors non-disruptive. Follows WCAG ARIA21/ARIA22 guidance.
2275
+ *
2276
+ * Error & Warning Display Behavior:
2277
+ * - The error display mode can be configured globally using the NGX_ERROR_DISPLAY_MODE_TOKEN injection token, or per instance using the `errorDisplayMode` input on FormErrorDisplayDirective (which this component uses as a hostDirective).
2278
+ * - Possible error display values: 'on-blur' | 'on-submit' | 'on-blur-or-submit' | 'on-dirty' | 'always' (default: 'on-blur-or-submit')
2279
+ * - The warning display mode can be configured globally using NGX_WARNING_DISPLAY_MODE_TOKEN, or per instance using the `warningDisplayMode` input on FormErrorDisplayDirective.
2280
+ * - Possible warning display values: 'on-touch' | 'on-validated-or-touch' | 'on-dirty' | 'always' (default: 'on-validated-or-touch')
2281
+ *
2282
+ * Example (per instance):
2283
+ * <div ngxControlWrapper>
2284
+ * <label>
2285
+ * <span>First name</span>
2286
+ * <input type="text" name="firstName" [ngModel]="formValue().firstName" />
2287
+ * </label>
2288
+ * </div>
2289
+ * /// To customize errorDisplayMode for this instance, use the errorDisplayMode input.
2290
+ *
2291
+ * Example (with warnings and pending):
2292
+ * <ngx-control-wrapper>
2293
+ * <input name="username" ngModel />
2294
+ * </ngx-control-wrapper>
2295
+ * /// If async validation is running for >200ms, a spinner and 'Validating…' will be shown.
2296
+ * /// Once shown, the validation message stays visible for minimum 500ms to prevent flashing.
2297
+ * /// If Vest warnings are present, they will be shown below errors.
2298
+ *
2299
+ * Example (global config):
2300
+ * import { provide } from '@angular/core';
2301
+ * import { NGX_ERROR_DISPLAY_MODE_TOKEN } from 'ngx-vest-forms';
2302
+ * @Component({
2303
+ * providers: [
2304
+ * provide(NGX_ERROR_DISPLAY_MODE_TOKEN, { useValue: 'submit' })
2305
+ * ]
2306
+ * })
2307
+ * export class MyComponent {}
2308
+ *
2309
+ * Best Practices:
2310
+ * - Use for every input or group in your forms.
2311
+ * - Do not manually display errors for individual fields; rely on this wrapper.
2312
+ * - Validate with tools like Accessibility Insights and real screen reader testing.
2313
+ *
2314
+ * @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA19 - ARIA19: Using ARIA role=alert
2315
+ * @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - ARIA22: Using role=status
2316
+ */
2317
+ class ControlWrapperComponent {
2318
+ mergeAriaDescribedBy(existing, wrapperActiveIds) {
2319
+ const existingTokens = (existing ?? '')
2320
+ .split(/\s+/)
2321
+ .map((t) => t.trim())
2322
+ .filter(Boolean);
2323
+ // Remove any previous wrapper-owned IDs from the existing list.
2324
+ const existingWithoutWrapper = existingTokens.filter((t) => !this.wrapperOwnedDescribedByIds.includes(t));
2325
+ // Append current wrapper IDs, preserving existing order and uniqueness.
2326
+ const merged = [...existingWithoutWrapper];
2327
+ for (const id of wrapperActiveIds) {
2328
+ if (!merged.includes(id)) {
2329
+ merged.push(id);
2112
2330
  }
2113
2331
  }
2114
- else {
2115
- // Update all form controls validity which will trigger all form events
2116
- this.ngForm.form.updateValueAndValidity({ emitEvent: true });
2117
- }
2118
- }
2119
- /**
2120
- * Convenience method to mark all form controls as touched.
2121
- *
2122
- * This is useful for showing all validation errors at once, typically when
2123
- * the user clicks a submit button. When a field is marked as touched,
2124
- * the error display logic (based on `errorDisplayMode`) will show its errors.
2125
- *
2126
- * **Note on automatic behavior:**
2127
- * When using the default error display mode (`on-blur-or-submit`), you typically
2128
- * don't need to call this method manually for regular form submissions. The form
2129
- * directive automatically marks all fields as touched on `ngSubmit`, so errors
2130
- * will display automatically when the user submits the form.
2131
- *
2132
- * **When to use this method:**
2133
- * - Multiple forms with a single submit button (forms without their own submit)
2134
- * - Programmatic form submission without triggering `ngSubmit`
2135
- * - Custom validation flows outside the normal submit process
2136
- *
2137
- * **Note:** This method only marks fields as touched—it does NOT re-run validation.
2138
- * If you also need to re-run validation (e.g., after structure changes), call
2139
- * `triggerFormValidation()` as well.
2140
- *
2141
- * @example
2142
- * ```typescript
2143
- * /// Standard form submission - NO need to call markAllAsTouched()
2144
- * /// The directive handles this automatically on ngSubmit
2145
- * <form ngxVestForm (ngSubmit)="save()">
2146
- * <button type="submit">Submit</button>
2147
- * </form>
2148
- *
2149
- * /// Multiple forms with one submit button
2150
- * submitAll() {
2151
- * this.form1().markAllAsTouched();
2152
- * this.form2().markAllAsTouched();
2153
- * if (this.form1().formState().valid && this.form2().formState().valid) {
2154
- * /// Submit all forms
2155
- * }
2156
- * }
2157
- * ```
2158
- */
2159
- markAllAsTouched() {
2160
- this.ngForm.form.markAllAsTouched();
2161
- }
2162
- /**
2163
- * Resets the form to a pristine, untouched state with optional new values.
2164
- *
2165
- * This method properly resets the form by:
2166
- * 1. Resetting Angular's underlying NgForm with the provided value
2167
- * 2. Clearing the bidirectional sync tracking state
2168
- * 3. Forcing a form validity update to clear any stale validation errors
2169
- *
2170
- * **Why this method exists:**
2171
- * When using the pattern `formValue.set({})` to reset a form, there can be a timing
2172
- * issue where the form controls in the DOM still hold their old values while the
2173
- * signal has already been updated. This creates a conflict in the bidirectional
2174
- * sync logic, requiring workarounds like calling `formValue.set({})` twice with
2175
- * a setTimeout. This method provides a proper solution by:
2176
- * - Calling Angular's `NgForm.resetForm()` which properly clears all controls
2177
- * - Clearing the internal sync tracking state to avoid stale comparisons
2178
- * - Triggering a form validity update to ensure validation state is current
2179
- *
2180
- * **Usage:**
2181
- * Instead of the double-set workaround:
2182
- * ```typescript
2183
- * // ❌ Old workaround (avoid)
2184
- * reset(): void {
2185
- * this.formValue.set({});
2186
- * setTimeout(() => this.formValue.set({}), 0);
2187
- * }
2188
- *
2189
- * // ✅ Preferred approach
2190
- * vestForm = viewChild.required('vestForm', { read: FormDirective });
2191
- * reset(): void {
2192
- * this.formValue.set({});
2193
- * this.vestForm().resetForm();
2194
- * }
2195
- * ```
2196
- *
2197
- * **With new values:**
2198
- * ```typescript
2199
- * // Reset and set new initial values
2200
- * resetWithDefaults(): void {
2201
- * const defaults = { firstName: '', lastName: '', age: 18 };
2202
- * this.formValue.set(defaults);
2203
- * this.vestForm().resetForm(defaults);
2204
- * }
2205
- * ```
2206
- *
2207
- * @param value - Optional new value to reset the form to. If not provided,
2208
- * resets to empty/default values.
2209
- *
2210
- * @see {@link markAllAsTouched} for showing validation errors
2211
- * @see {@link triggerFormValidation} for re-running validation without reset
2212
- */
2213
- resetForm(value) {
2214
- // Reset Angular's form to clear all controls and mark as pristine/untouched
2215
- this.ngForm.resetForm(value ?? undefined);
2216
- // Clear the bidirectional sync tracking state so the next formValue change
2217
- // is treated as a model change (not a conflict with stale form values)
2218
- this.#lastSyncedFormValue = null;
2219
- this.#lastSyncedModelValue = null;
2220
- this.#lastLinkedValue = null;
2221
- // Force change detection to ensure DOM updates are reflected
2222
- // Note: This is still needed even with signals because we're modifying NgForm
2223
- // (reactive forms), not signals. The formValue signal updates happen in the
2224
- // consumer component. detectChanges() ensures NgForm's reset is reflected in
2225
- // the DOM before we update validity.
2226
- this.cdr.detectChanges();
2227
- // Trigger validation update to clear any stale errors
2228
- // Now synchronous since detectChanges() has flushed DOM updates
2229
- this.ngForm.form.updateValueAndValidity({ emitEvent: true });
2332
+ return merged.length > 0 ? merged.join(' ') : null;
2230
2333
  }
2231
- /**
2232
- * This will feed the formValueCache, debounce it till the next tick
2233
- * and create an asynchronous validator that runs a vest suite
2234
- * @param field
2235
- * @param validationOptions
2236
- * @returns an asynchronous validator function
2237
- */
2238
- /**
2239
- * V2 async validator pattern: uses timer() + switchMap for proper re-validation.
2240
- * Each invocation builds a fresh one-shot observable, ensuring progressive validation.
2241
- */
2242
- createAsyncValidator(field, validationOptions) {
2243
- const suite = this.suite();
2244
- if (!suite)
2245
- return () => of(null);
2246
- return (control) => {
2247
- const model = mergeValuesAndRawValues(this.ngForm.form);
2248
- // Targeted snapshot with candidate value injected at path
2249
- // mergeValuesAndRawValues already returns a deep clone (via structuredClone),
2250
- // so we can modify it directly without affecting the form state.
2251
- const snapshot = model;
2252
- setValueAtPath(snapshot, field, control.value);
2253
- // Use timer() instead of ReplaySubject for proper debouncing
2254
- return timer(validationOptions.debounceTime ?? 0).pipe(map(() => snapshot), switchMap((snap) => new Observable((observer) => {
2255
- try {
2256
- // Cast to NgxVestSuite to accept string field parameter
2257
- // Both NgxVestSuite and NgxTypedVestSuite work with string at runtime
2258
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2259
- suite(snap, field).done((result) => {
2260
- const errors = result.getErrors()[field];
2261
- const warnings = result.getWarnings()[field];
2262
- const out = errors?.length || warnings?.length
2263
- ? {
2264
- ...(errors?.length && { errors }),
2265
- ...(warnings?.length && { warnings }),
2266
- }
2267
- : null;
2268
- // CRITICAL: Ensure DOM validity classes update for OnPush components.
2269
- //
2270
- // Angular's template-driven forms update `ng-valid`/`ng-invalid` host classes
2271
- // during change detection. When async validation completes, there may be no
2272
- // follow-up change detection pass for OnPush hosts, leaving the DOM in a stale
2273
- // visual state (even though the control status has updated).
2274
- //
2275
- // We schedule a detectChanges() on the next microtask to avoid calling it
2276
- // synchronously inside Angular's own validation pipeline.
2277
- queueMicrotask(() => {
2278
- try {
2279
- this.cdr.detectChanges();
2280
- }
2281
- catch {
2282
- // Fallback: mark for check when immediate detectChanges isn't safe.
2283
- // This keeps behavior resilient in edge cases.
2284
- this.cdr.markForCheck();
2285
- }
2286
- });
2287
- observer.next(out);
2288
- observer.complete();
2289
- });
2334
+ constructor() {
2335
+ this.errorDisplay = inject(FormErrorDisplayDirective, {
2336
+ self: true,
2337
+ });
2338
+ this.elementRef = inject(ElementRef);
2339
+ this.destroyRef = inject(DestroyRef);
2340
+ /**
2341
+ * Controls how this wrapper applies ARIA attributes to descendant controls.
2342
+ *
2343
+ * - `all-controls` (default, backwards compatible): apply `aria-describedby` / `aria-invalid`
2344
+ * to all `input/select/textarea` elements inside the wrapper.
2345
+ * - `single-control`: apply ARIA attributes only when exactly one control is found.
2346
+ * (Useful for wrappers that sometimes contain helper buttons/controls.)
2347
+ * - `none`: do not mutate descendant controls at all (group-safe mode).
2348
+ *
2349
+ * Notes:
2350
+ * - Use `none` when wrapping a container (e.g. `NgModelGroup`) to avoid stamping ARIA
2351
+ * across multiple child controls.
2352
+ * - This does not affect whether messages render; it only affects ARIA wiring.
2353
+ */
2354
+ this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
2355
+ // Generate unique IDs for ARIA associations
2356
+ this.uniqueId = `ngx-control-wrapper-${nextUniqueId$2++}`;
2357
+ this.errorId = `${this.uniqueId}-error`;
2358
+ this.warningId = `${this.uniqueId}-warning`;
2359
+ this.pendingId = `${this.uniqueId}-pending`;
2360
+ // Track form controls found in the wrapper
2361
+ this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
2362
+ // Signals when content is initialized so effects can safely touch the DOM.
2363
+ this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
2364
+ // MutationObserver to detect dynamically added/removed controls
2365
+ this.mutationObserver = null;
2366
+ /**
2367
+ * Debounced pending state to prevent flashing for quick async validations.
2368
+ * Uses createDebouncedPendingState utility with 500ms delay and 500ms minimum display.
2369
+ */
2370
+ this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
2371
+ this.showPendingMessage = this.pendingState.showPendingMessage;
2372
+ /**
2373
+ * Whether to display warnings.
2374
+ * Delegates to FormErrorDisplayDirective's centralized shouldShowWarnings signal.
2375
+ *
2376
+ * This ensures consistent warning display behavior across all form components
2377
+ * and supports the new 'on-dirty' and 'always' display modes.
2378
+ */
2379
+ this.shouldShowWarnings = this.errorDisplay.shouldShowWarnings;
2380
+ /**
2381
+ * Computed signal that builds aria-describedby string based on visible regions
2382
+ */
2383
+ this.ariaDescribedBy = computed(() => {
2384
+ const ids = [];
2385
+ if (this.errorDisplay.shouldShowErrors()) {
2386
+ ids.push(this.errorId);
2387
+ }
2388
+ if (this.shouldShowWarnings()) {
2389
+ ids.push(this.warningId);
2390
+ }
2391
+ if (this.showPendingMessage()) {
2392
+ ids.push(this.pendingId);
2393
+ }
2394
+ return ids.length > 0 ? ids.join(' ') : null;
2395
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
2396
+ /**
2397
+ * IDs managed by this wrapper when composing aria-describedby.
2398
+ *
2399
+ * We remove only these from the consumer-provided aria-describedby tokens and then
2400
+ * append the currently-relevant wrapper IDs. This prevents clobbering app-provided
2401
+ * hint/help text associations.
2402
+ */
2403
+ this.wrapperOwnedDescribedByIds = [
2404
+ this.errorId,
2405
+ this.warningId,
2406
+ this.pendingId,
2407
+ ];
2408
+ // Effect to update aria-describedby and aria-invalid on form controls
2409
+ effect(() => {
2410
+ if (!this.contentInitialized())
2411
+ return;
2412
+ const mode = this.ariaAssociationMode();
2413
+ if (mode === 'none') {
2414
+ return;
2415
+ }
2416
+ const describedBy = this.ariaDescribedBy();
2417
+ const wrapperActiveIds = describedBy
2418
+ ? describedBy.split(/\s+/).filter(Boolean)
2419
+ : [];
2420
+ const shouldShowErrors = this.errorDisplay.shouldShowErrors();
2421
+ const targets = (() => {
2422
+ const controls = this.formControls();
2423
+ if (mode === 'single-control') {
2424
+ return controls.length === 1 ? controls : [];
2290
2425
  }
2291
- catch {
2292
- observer.next({ vestInternalError: 'Validation failed' });
2293
- observer.complete();
2426
+ return controls;
2427
+ })();
2428
+ targets.forEach((control) => {
2429
+ // Update aria-describedby (merge, don't overwrite)
2430
+ const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), wrapperActiveIds);
2431
+ if (nextDescribedBy) {
2432
+ control.setAttribute('aria-describedby', nextDescribedBy);
2294
2433
  }
2295
- })), catchError(() => of({ vestInternalError: 'Validation failed' })), take(1));
2296
- };
2434
+ else {
2435
+ control.removeAttribute('aria-describedby');
2436
+ }
2437
+ // Update aria-invalid
2438
+ if (shouldShowErrors) {
2439
+ control.setAttribute('aria-invalid', 'true');
2440
+ }
2441
+ else {
2442
+ control.removeAttribute('aria-invalid');
2443
+ }
2444
+ });
2445
+ });
2446
+ // Clean up MutationObserver when component is destroyed
2447
+ this.destroyRef.onDestroy(() => {
2448
+ this.mutationObserver?.disconnect();
2449
+ this.mutationObserver = null;
2450
+ });
2451
+ // Effect to enable/disable DOM observation based on ariaAssociationMode.
2452
+ // This keeps the wrapper cheap in group-safe mode.
2453
+ effect(() => {
2454
+ if (!this.contentInitialized())
2455
+ return;
2456
+ const mode = this.ariaAssociationMode();
2457
+ if (mode === 'none') {
2458
+ this.mutationObserver?.disconnect();
2459
+ this.mutationObserver = null;
2460
+ if (this.formControls().length > 0) {
2461
+ this.formControls.set([]);
2462
+ }
2463
+ return;
2464
+ }
2465
+ // Ensure controls list is up to date.
2466
+ this.updateFormControls();
2467
+ // Ensure MutationObserver is installed (dynamic @if/@for support).
2468
+ if (!this.mutationObserver) {
2469
+ this.mutationObserver = new MutationObserver(() => {
2470
+ this.updateFormControls();
2471
+ });
2472
+ this.mutationObserver.observe(this.elementRef.nativeElement, {
2473
+ childList: true,
2474
+ subtree: true,
2475
+ });
2476
+ }
2477
+ });
2297
2478
  }
2298
- /**
2299
- * Set up validation config reactively using v2 pattern with toObservable + switchMap.
2300
- * This provides automatic cleanup when config changes.
2301
- */
2302
- #setupValidationConfig() {
2303
- const form = this.ngForm.form;
2304
- toObservable(this.validationConfig)
2305
- .pipe(distinctUntilChanged(), switchMap((config) => {
2306
- // Cast to the expected type for the helper method
2307
- const typedConfig = config;
2308
- return this.#createValidationStreams(form, typedConfig);
2309
- }), takeUntilDestroyed(this.destroyRef))
2310
- .subscribe();
2479
+ ngAfterContentInit() {
2480
+ this.contentInitialized.set(true);
2481
+ // ARIA wiring + observer setup is managed by effects so that the wrapper can
2482
+ // opt out (ariaAssociationMode="none").
2311
2483
  }
2312
- /**
2313
- * Creates validation streams for the provided configuration.
2314
- * Returns EMPTY if config is null/undefined, otherwise merges all trigger field streams.
2315
- *
2316
- * @param form - The NgForm instance
2317
- * @param config - The validation configuration mapping trigger fields to dependent fields
2318
- * @returns Observable that emits when any trigger field changes and dependent fields need validation
2319
- */
2320
- #createValidationStreams(form, config) {
2321
- if (!config) {
2322
- this.validationInProgress.clear();
2323
- return EMPTY;
2324
- }
2325
- const streams = Object.keys(config).map((triggerField) => {
2326
- const dependents = config[triggerField] || [];
2327
- return this.#createTriggerStream(form, triggerField, dependents);
2328
- });
2329
- return streams.length > 0 ? merge(...streams) : EMPTY;
2484
+ ngOnDestroy() {
2485
+ this.mutationObserver?.disconnect();
2486
+ this.mutationObserver = null;
2330
2487
  }
2331
2488
  /**
2332
- * Creates a stream for a single trigger field that revalidates its dependent fields.
2333
- *
2334
- * This method handles:
2335
- * 1. Waiting for the trigger control to exist in the form (for @if scenarios)
2336
- * 2. Listening to value changes with debouncing
2337
- * 3. Waiting for form to be idle before triggering dependents
2338
- * 4. Waiting for all dependent controls to exist
2339
- * 5. Updating dependent field validity with loop prevention
2340
- * 6. Touch state propagation from trigger to dependents
2341
- *
2342
- * @param form - The NgForm instance
2343
- * @param triggerField - Field path that triggers validation (e.g., 'password')
2344
- * @param dependents - Array of dependent field paths to revalidate (e.g., ['confirmPassword'])
2345
- * @returns Observable that completes after dependent fields are validated
2489
+ * Query and update the list of form controls within this wrapper.
2490
+ * Called on init and whenever the DOM structure changes.
2346
2491
  */
2347
- #createTriggerStream(form, triggerField, dependents) {
2348
- // Wait for trigger control to exist, then stop listening (take(1) prevents feedback loops)
2349
- const triggerControl$ = form.statusChanges.pipe(startWith(form.status), map(() => form.get(triggerField)), filter((c) => !!c),
2350
- // CRITICAL: take(1) to stop listening after control is found
2351
- // Without this, the pipeline continues to listen to statusChanges,
2352
- // creating a feedback loop where validation triggers re-trigger the pipeline
2353
- take(1));
2354
- return triggerControl$.pipe(switchMap((control) => {
2355
- return control.valueChanges.pipe(
2356
- // CRITICAL: Filter out changes when this field is being validated by another field's config
2357
- // This prevents circular triggers in bidirectional validationConfig
2358
- filter(() => !this.validationInProgress.has(triggerField)), debounceTime(this.configDebounceTime), switchMap(() => {
2359
- return this.#waitForFormIdle(form, control);
2360
- }), switchMap(() => this.#waitForDependentControls(form, dependents, control)), tap(() => this.#updateDependentFields(form, control, triggerField, dependents)));
2361
- }));
2492
+ updateFormControls() {
2493
+ const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
2494
+ this.formControls.set(Array.from(controls));
2362
2495
  }
2363
- /**
2364
- * Waits for the form to reach a non-PENDING state before proceeding.
2365
- * This prevents validation race conditions where dependent field validation
2366
- * triggers while the trigger field's validation is still running.
2367
- *
2368
- * If the form stays PENDING for longer than 2 seconds (e.g., slow async validators),
2369
- * proceeds anyway to prevent blocking the validation pipeline.
2370
- *
2371
- * @param form - The NgForm instance
2372
- * @param control - The trigger control to pass through
2373
- * @returns Observable that emits the control once form is idle or timeout
2374
- */
2375
- #waitForFormIdle(form, control) {
2376
- // If form is already non-PENDING, return immediately
2377
- if (form.status !== 'PENDING') {
2378
- return of(control);
2379
- }
2380
- // Form is PENDING, wait for it to become idle
2381
- const idle$ = form.statusChanges.pipe(filter((s) => s !== 'PENDING'), take(1));
2382
- const timeout$ = timer(2000);
2383
- return race(idle$, timeout$).pipe(map(() => control));
2496
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2497
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.1", type: ControlWrapperComponent, isStandalone: true, selector: "ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-control-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-control-wrapper sc-control-wrapper" }, hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode", "warningDisplayMode", "warningDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n WCAG 2.2 AA Compliance for Inline Field Validation:\n\n IMPORTANT DISTINCTION - Field-level vs Form-level errors:\n - FIELD-LEVEL errors (this component): Use role=\"status\" with aria-live=\"polite\"\n These are inline validation messages that appear as users interact with fields.\n Using \"assertive\" would be too disruptive when users are filling out multiple fields.\n - FORM-LEVEL blocking errors (e.g., submission failed): Should use role=\"alert\" with\n aria-live=\"assertive\" in a separate form-level error summary component.\n\n This component handles FIELD-LEVEL messages:\n - Errors: role=\"status\" with aria-live=\"polite\" (non-disruptive, field is already\n marked with aria-invalid=\"true\" and aria-describedby points here)\n - Warnings: role=\"status\" with aria-live=\"polite\" (informational, doesn't block)\n - Pending: role=\"status\" with aria-live=\"polite\" (status update)\n\n The aria-invalid=\"true\" + aria-describedby association on the input provides\n the primary accessibility pathway; the live region is supplementary.\n\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA21 - Using aria-invalid\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - Using role=status\n-->\n<div\n [id]=\"errorId\"\n class=\"text-sm text-red-600\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.shouldShowErrors()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<!--\n Warnings: Non-blocking validation messages (Vest warn())\n - Use role=\"status\" with aria-live=\"polite\" per WCAG ARIA22\n - Default: show after validation runs or touch; configurable to require touch only\n - Warnings do NOT affect field validity - they're informational only\n-->\n<div\n [id]=\"warningId\"\n class=\"text-sm text-yellow-700\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (shouldShowWarnings()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<!-- Pending state is also stable; content appears only after the debounce delay -->\n<div\n [id]=\"pendingId\"\n class=\"absolute top-0 right-0 flex items-center gap-1 text-xs text-gray-500\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (showPendingMessage()) {\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n aria-hidden=\"true\"\n ></span>\n Validating\u2026\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
2498
+ }
2499
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ControlWrapperComponent, decorators: [{
2500
+ type: Component,
2501
+ args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2502
+ class: 'ngx-control-wrapper sc-control-wrapper',
2503
+ '[class.ngx-control-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
2504
+ '[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
2505
+ }, hostDirectives: [
2506
+ {
2507
+ directive: FormErrorDisplayDirective,
2508
+ inputs: ['errorDisplayMode', 'warningDisplayMode'],
2509
+ },
2510
+ ], template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n WCAG 2.2 AA Compliance for Inline Field Validation:\n\n IMPORTANT DISTINCTION - Field-level vs Form-level errors:\n - FIELD-LEVEL errors (this component): Use role=\"status\" with aria-live=\"polite\"\n These are inline validation messages that appear as users interact with fields.\n Using \"assertive\" would be too disruptive when users are filling out multiple fields.\n - FORM-LEVEL blocking errors (e.g., submission failed): Should use role=\"alert\" with\n aria-live=\"assertive\" in a separate form-level error summary component.\n\n This component handles FIELD-LEVEL messages:\n - Errors: role=\"status\" with aria-live=\"polite\" (non-disruptive, field is already\n marked with aria-invalid=\"true\" and aria-describedby points here)\n - Warnings: role=\"status\" with aria-live=\"polite\" (informational, doesn't block)\n - Pending: role=\"status\" with aria-live=\"polite\" (status update)\n\n The aria-invalid=\"true\" + aria-describedby association on the input provides\n the primary accessibility pathway; the live region is supplementary.\n\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA21 - Using aria-invalid\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - Using role=status\n-->\n<div\n [id]=\"errorId\"\n class=\"text-sm text-red-600\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.shouldShowErrors()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<!--\n Warnings: Non-blocking validation messages (Vest warn())\n - Use role=\"status\" with aria-live=\"polite\" per WCAG ARIA22\n - Default: show after validation runs or touch; configurable to require touch only\n - Warnings do NOT affect field validity - they're informational only\n-->\n<div\n [id]=\"warningId\"\n class=\"text-sm text-yellow-700\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (shouldShowWarnings()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<!-- Pending state is also stable; content appears only after the debounce delay -->\n<div\n [id]=\"pendingId\"\n class=\"absolute top-0 right-0 flex items-center gap-1 text-xs text-gray-500\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (showPendingMessage()) {\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n aria-hidden=\"true\"\n ></span>\n Validating\u2026\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"] }]
2511
+ }], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
2512
+
2513
+ let nextUniqueId$1 = 0;
2514
+ /**
2515
+ * Group-safe wrapper for `NgModelGroup` containers.
2516
+ *
2517
+ * This component renders group-level errors/warnings/pending UI, but intentionally
2518
+ * does **not** stamp `aria-describedby` / `aria-invalid` onto descendant controls.
2519
+ *
2520
+ * Use this when you want a wrapper around a container that has multiple inputs.
2521
+ * For single inputs, prefer `<ngx-control-wrapper>`.
2522
+ */
2523
+ class FormGroupWrapperComponent {
2524
+ constructor() {
2525
+ this.errorDisplay = inject(FormErrorDisplayDirective, {
2526
+ self: true,
2527
+ });
2528
+ /**
2529
+ * Controls the debounce behavior for the pending message.
2530
+ * Defaults are conservative to avoid flashing.
2531
+ */
2532
+ this.pendingDebounce = input({
2533
+ showAfter: 500,
2534
+ minimumDisplay: 500,
2535
+ }, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : []));
2536
+ this.uniqueId = `ngx-form-group-wrapper-${nextUniqueId$1++}`;
2537
+ this.errorId = `${this.uniqueId}-error`;
2538
+ this.warningId = `${this.uniqueId}-warning`;
2539
+ this.pendingId = `${this.uniqueId}-pending`;
2540
+ this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce());
2541
+ this.showPendingMessage = this.pendingState.showPendingMessage;
2542
+ /**
2543
+ * Helpful if consumers want to wire aria-describedby manually (e.g. fieldset/legend pattern).
2544
+ */
2545
+ this.describedByIds = computed(() => {
2546
+ const ids = [];
2547
+ if (this.errorDisplay.shouldShowErrors()) {
2548
+ ids.push(this.errorId);
2549
+ }
2550
+ if (this.errorDisplay.warnings().length > 0) {
2551
+ ids.push(this.warningId);
2552
+ }
2553
+ if (this.showPendingMessage()) {
2554
+ ids.push(this.pendingId);
2555
+ }
2556
+ return ids.length > 0 ? ids.join(' ') : null;
2557
+ }, ...(ngDevMode ? [{ debugName: "describedByIds" }] : []));
2384
2558
  }
2385
- /**
2386
- * Waits for all dependent controls to exist in the form.
2387
- * This handles @if scenarios where controls are conditionally rendered.
2388
- *
2389
- * @param form - The NgForm instance
2390
- * @param dependents - Array of dependent field paths
2391
- * @param control - The trigger control to pass through
2392
- * @returns Observable that emits the control once all dependents exist
2393
- */
2394
- #waitForDependentControls(form, dependents, control) {
2395
- const allDependentsExist = dependents.every((depField) => !!form.get(depField));
2396
- if (allDependentsExist) {
2397
- return of(control);
2559
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2560
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.1", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]", inputs: { pendingDebounce: { classPropertyName: "pendingDebounce", publicName: "pendingDebounce", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-form-group-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-form-group-wrapper sc-form-group-wrapper" }, exportAs: ["formGroupWrapper", "ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-form-group-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Keep regions stable in the DOM so IDs are always valid targets.\n This wrapper does NOT modify descendant controls.\n-->\n<div [id]=\"errorId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowErrors() && errorDisplay.errors().length > 0) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"warningId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"pendingId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (showPendingMessage()) {\n <span aria-hidden=\"true\">Validating\u2026</span>\n }\n</div>\n", styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
2561
+ }
2562
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
2563
+ type: Component,
2564
+ args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]', exportAs: 'formGroupWrapper, ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2565
+ class: 'ngx-form-group-wrapper sc-form-group-wrapper',
2566
+ '[class.ngx-form-group-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
2567
+ '[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
2568
+ }, hostDirectives: [
2569
+ {
2570
+ directive: FormErrorDisplayDirective,
2571
+ inputs: ['errorDisplayMode'],
2572
+ },
2573
+ ], template: "<div class=\"ngx-form-group-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Keep regions stable in the DOM so IDs are always valid targets.\n This wrapper does NOT modify descendant controls.\n-->\n<div [id]=\"errorId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowErrors() && errorDisplay.errors().length > 0) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"warningId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"pendingId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (showPendingMessage()) {\n <span aria-hidden=\"true\">Validating\u2026</span>\n }\n</div>\n", styles: [":host{display:block}\n"] }]
2574
+ }], propDecorators: { pendingDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingDebounce", required: false }] }] } });
2575
+
2576
+ let nextUniqueId = 0;
2577
+ /**
2578
+ * Wires a control container to its error/warning/pending regions.
2579
+ *
2580
+ * This directive is intended for custom wrappers/components.
2581
+ * It composes `FormErrorDisplayDirective` (and thus `FormControlStateDirective`)
2582
+ * and applies `aria-invalid` / `aria-describedby` to descendant controls.
2583
+ *
2584
+ * It does not render any UI; you can use the generated IDs to render messages.
2585
+ */
2586
+ class FormErrorControlDirective {
2587
+ mergeAriaDescribedBy(existing, activeIds) {
2588
+ const existingTokens = (existing ?? '')
2589
+ .split(/\s+/)
2590
+ .map((t) => t.trim())
2591
+ .filter(Boolean);
2592
+ const existingWithoutOwned = existingTokens.filter((t) => !this.ownedDescribedByIds.includes(t));
2593
+ const merged = [...existingWithoutOwned];
2594
+ for (const id of activeIds) {
2595
+ if (!merged.includes(id)) {
2596
+ merged.push(id);
2597
+ }
2398
2598
  }
2399
- // Wait for dependent controls to be added to the form
2400
- return form.statusChanges.pipe(startWith(form.status), filter(() => dependents.every((depField) => !!form.get(depField))), take(1), map(() => control));
2599
+ return merged.length > 0 ? merged.join(' ') : null;
2401
2600
  }
2402
- /**
2403
- * Updates validation for all dependent fields.
2404
- *
2405
- * Handles:
2406
- * - Touch state propagation (mark dependents touched when trigger is touched)
2407
- * - Loop prevention via validationInProgress set
2408
- * - Silent validation (emitEvent: false) to prevent feedback loops
2409
- *
2410
- * @param form - The NgForm instance
2411
- * @param control - The trigger control
2412
- * @param triggerField - Field path of the trigger
2413
- * @param dependents - Array of dependent field paths to update
2414
- */
2415
- #updateDependentFields(form, control, triggerField, dependents) {
2416
- // Mark trigger field as in-progress to prevent it from being re-triggered
2417
- this.validationInProgress.add(triggerField);
2418
- for (const depField of dependents) {
2419
- const dependentControl = form.get(depField);
2420
- if (!dependentControl) {
2421
- continue;
2601
+ constructor() {
2602
+ this.errorDisplay = inject(FormErrorDisplayDirective, {
2603
+ self: true,
2604
+ });
2605
+ this.elementRef = inject((ElementRef));
2606
+ /**
2607
+ * Controls how this directive applies ARIA attributes to descendant controls.
2608
+ *
2609
+ * - `all-controls` (default): apply ARIA attributes to all input/select/textarea descendants.
2610
+ * - `single-control`: apply ARIA attributes only when exactly one control is found.
2611
+ * - `none`: do not mutate descendant controls.
2612
+ */
2613
+ this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
2614
+ /**
2615
+ * Unique ID prefix for this instance.
2616
+ * Use these IDs to render message regions and to support aria-describedby.
2617
+ */
2618
+ this.uniqueId = `ngx-error-control-${nextUniqueId++}`;
2619
+ this.errorId = `${this.uniqueId}-error`;
2620
+ this.warningId = `${this.uniqueId}-warning`;
2621
+ this.pendingId = `${this.uniqueId}-pending`;
2622
+ this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
2623
+ this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
2624
+ this.mutationObserver = null;
2625
+ this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
2626
+ this.showPendingMessage = this.pendingState.showPendingMessage;
2627
+ /**
2628
+ * aria-describedby value representing the *currently relevant* message regions.
2629
+ */
2630
+ this.ariaDescribedBy = computed(() => {
2631
+ const ids = [];
2632
+ if (this.errorDisplay.shouldShowErrors()) {
2633
+ ids.push(this.errorId);
2422
2634
  }
2423
- // Only validate if not already in progress (prevents bidirectional loops)
2424
- if (!this.validationInProgress.has(depField)) {
2425
- // CRITICAL: Mark the dependent field as in-progress BEFORE calling updateValueAndValidity
2426
- // This prevents the dependent field's valueChanges from triggering its own validationConfig
2427
- this.validationInProgress.add(depField);
2428
- // Propagate touch state BEFORE validation to avoid duplicate class updates
2429
- // markAsTouched() triggers change detection, so we do it once before updateValueAndValidity
2430
- if (control.touched && !dependentControl.touched) {
2431
- dependentControl.markAsTouched({ onlySelf: true });
2635
+ if (this.errorDisplay.warnings().length > 0) {
2636
+ ids.push(this.warningId);
2637
+ }
2638
+ if (this.showPendingMessage()) {
2639
+ ids.push(this.pendingId);
2640
+ }
2641
+ return ids.length > 0 ? ids.join(' ') : null;
2642
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
2643
+ this.ownedDescribedByIds = [
2644
+ this.errorId,
2645
+ this.warningId,
2646
+ this.pendingId,
2647
+ ];
2648
+ // Effect for ARIA attribute updates
2649
+ effect(() => {
2650
+ if (!this.contentInitialized())
2651
+ return;
2652
+ const mode = this.ariaAssociationMode();
2653
+ if (mode === 'none')
2654
+ return;
2655
+ const describedBy = this.ariaDescribedBy();
2656
+ const activeIds = describedBy
2657
+ ? describedBy.split(/\s+/).filter(Boolean)
2658
+ : [];
2659
+ const shouldShowErrors = this.errorDisplay.shouldShowErrors();
2660
+ const targets = (() => {
2661
+ const controls = this.formControls();
2662
+ if (mode === 'single-control') {
2663
+ return controls.length === 1 ? controls : [];
2664
+ }
2665
+ return controls;
2666
+ })();
2667
+ for (const control of targets) {
2668
+ const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), activeIds);
2669
+ if (nextDescribedBy) {
2670
+ control.setAttribute('aria-describedby', nextDescribedBy);
2671
+ }
2672
+ else {
2673
+ control.removeAttribute('aria-describedby');
2674
+ }
2675
+ if (shouldShowErrors) {
2676
+ control.setAttribute('aria-invalid', 'true');
2677
+ }
2678
+ else {
2679
+ control.removeAttribute('aria-invalid');
2432
2680
  }
2433
- // emitEvent: true is REQUIRED for async validators to actually run
2434
- // The validationInProgress Set prevents infinite loops:
2435
- // 1. Field A changes → triggers validation on dependent field B
2436
- // 2. B is added to validationInProgress Set
2437
- // 3. B's statusChanges emits → #handleValueChange checks validationInProgress
2438
- // 4. Since B is in validationInProgress, its validationConfig is not triggered
2439
- // 5. After 500ms timeout, B is removed from validationInProgress
2440
- // This way:
2441
- // - Async validators CAN run (emitEvent: true)
2442
- // - BUT circular triggers are prevented (validationInProgress check)
2443
- dependentControl.updateValueAndValidity({
2444
- onlySelf: true,
2445
- emitEvent: true, // Changed from false - REQUIRED for validators to run!
2446
- });
2447
- // CRITICAL: Force immediate change detection for OnPush components
2448
- // updateValueAndValidity updates the control's status, but doesn't automatically
2449
- // trigger change detection. Components using OnPush won't see the ng-invalid class
2450
- // update in the DOM without this. Using detectChanges() instead of markForCheck()
2451
- // to force immediate synchronous update rather than waiting for next CD cycle.
2452
- this.cdr.detectChanges();
2453
2681
  }
2454
- }
2455
- // Keep fields marked as in-progress for a short time to prevent immediate re-triggering
2456
- // Use setTimeout to ensure async validators have time to complete before allowing new triggers
2457
- // INCREASED from 100ms to 500ms to fix bidirectional validation issues with password/date fields
2458
- // This gives validators enough time to complete and propagate before allowing re-triggering
2459
- // Bidirectional validations need more time because both fields validate each other
2460
- setTimeout(() => {
2461
- this.validationInProgress.delete(triggerField);
2462
- for (const depField of dependents) {
2463
- this.validationInProgress.delete(depField);
2682
+ });
2683
+ // Effect for MutationObserver setup with proper cleanup
2684
+ effect((onCleanup) => {
2685
+ if (!this.contentInitialized())
2686
+ return;
2687
+ const mode = this.ariaAssociationMode();
2688
+ if (mode === 'none') {
2689
+ this.mutationObserver?.disconnect();
2690
+ this.mutationObserver = null;
2691
+ if (this.formControls().length > 0) {
2692
+ this.formControls.set([]);
2693
+ }
2694
+ return;
2695
+ }
2696
+ this.updateFormControls();
2697
+ if (!this.mutationObserver) {
2698
+ this.mutationObserver = new MutationObserver(() => {
2699
+ this.updateFormControls();
2700
+ });
2701
+ this.mutationObserver.observe(this.elementRef.nativeElement, {
2702
+ childList: true,
2703
+ subtree: true,
2704
+ });
2464
2705
  }
2465
- }, 500);
2706
+ // Proper cleanup using onCleanup callback (Angular 21 best practice)
2707
+ onCleanup(() => {
2708
+ this.mutationObserver?.disconnect();
2709
+ this.mutationObserver = null;
2710
+ });
2711
+ });
2712
+ }
2713
+ ngAfterContentInit() {
2714
+ this.contentInitialized.set(true);
2715
+ }
2716
+ ngOnDestroy() {
2717
+ this.mutationObserver?.disconnect();
2718
+ this.mutationObserver = null;
2466
2719
  }
2467
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2468
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: FormDirective, isStandalone: true, selector: "form[scVestForm], form[ngxVestForm]", inputs: { formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, formShape: { classPropertyName: "formShape", publicName: "formShape", isSignal: true, isRequired: false, transformFunction: null }, validationConfig: { classPropertyName: "validationConfig", publicName: "validationConfig", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formValueChange: "formValueChange", errorsChange: "errorsChange", dirtyChange: "dirtyChange", validChange: "validChange" }, exportAs: ["scVestForm", "ngxVestForm"], ngImport: i0 }); }
2720
+ updateFormControls() {
2721
+ const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
2722
+ this.formControls.set(Array.from(controls));
2723
+ }
2724
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2725
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormErrorControlDirective, isStandalone: true, selector: "[formErrorControl], [ngxErrorControl]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorControl", "ngxErrorControl"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0 }); }
2469
2726
  }
2470
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormDirective, decorators: [{
2727
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorControlDirective, decorators: [{
2471
2728
  type: Directive,
2472
2729
  args: [{
2473
- selector: 'form[scVestForm], form[ngxVestForm]',
2474
- exportAs: 'scVestForm, ngxVestForm',
2730
+ selector: '[formErrorControl], [ngxErrorControl]',
2731
+ exportAs: 'formErrorControl, ngxErrorControl',
2732
+ hostDirectives: [
2733
+ {
2734
+ directive: FormErrorDisplayDirective,
2735
+ inputs: ['errorDisplayMode'],
2736
+ },
2737
+ ],
2475
2738
  }]
2476
- }], ctorParameters: () => [], propDecorators: { formValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "formValue", required: false }] }], suite: [{ type: i0.Input, args: [{ isSignal: true, alias: "suite", required: false }] }], formShape: [{ type: i0.Input, args: [{ isSignal: true, alias: "formShape", required: false }] }], validationConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationConfig", required: false }] }], formValueChange: [{ type: i0.Output, args: ["formValueChange"] }], errorsChange: [{ type: i0.Output, args: ["errorsChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], validChange: [{ type: i0.Output, args: ["validChange"] }] } });
2739
+ }], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
2477
2740
 
2478
2741
  /**
2479
2742
  * Hooks into the ngModelGroup selector and triggers an asynchronous validation for a form group
@@ -2511,8 +2774,8 @@ class FormModelGroupDirective {
2511
2774
  return of(null);
2512
2775
  }
2513
2776
  }
2514
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2515
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup],[ngxModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2777
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2778
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup],[ngxModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2516
2779
  {
2517
2780
  provide: NG_ASYNC_VALIDATORS,
2518
2781
  useExisting: FormModelGroupDirective,
@@ -2520,7 +2783,7 @@ class FormModelGroupDirective {
2520
2783
  },
2521
2784
  ], ngImport: i0 }); }
2522
2785
  }
2523
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormModelGroupDirective, decorators: [{
2786
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelGroupDirective, decorators: [{
2524
2787
  type: Directive,
2525
2788
  args: [{
2526
2789
  selector: '[ngModelGroup],[ngxModelGroup]',
@@ -2578,8 +2841,8 @@ class FormModelDirective {
2578
2841
  return of(null);
2579
2842
  }
2580
2843
  }
2581
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2582
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: FormModelDirective, isStandalone: true, selector: "[ngModel],[ngxModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2844
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2845
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormModelDirective, isStandalone: true, selector: "[ngModel],[ngxModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2583
2846
  {
2584
2847
  provide: NG_ASYNC_VALIDATORS,
2585
2848
  useExisting: FormModelDirective,
@@ -2587,7 +2850,7 @@ class FormModelDirective {
2587
2850
  },
2588
2851
  ], ngImport: i0 }); }
2589
2852
  }
2590
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormModelDirective, decorators: [{
2853
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelDirective, decorators: [{
2591
2854
  type: Directive,
2592
2855
  args: [{
2593
2856
  selector: '[ngModel],[ngxModel]',
@@ -2824,8 +3087,8 @@ class ValidateRootFormDirective {
2824
3087
  }), take(1), takeUntilDestroyed(this.destroyRef));
2825
3088
  };
2826
3089
  }
2827
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2828
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: ValidateRootFormDirective, isStandalone: true, selector: "form[validateRootForm], form[ngxValidateRootForm]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null }, formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, validateRootForm: { classPropertyName: "validateRootForm", publicName: "validateRootForm", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootForm: { classPropertyName: "ngxValidateRootForm", publicName: "ngxValidateRootForm", isSignal: true, isRequired: false, transformFunction: null }, validateRootFormMode: { classPropertyName: "validateRootFormMode", publicName: "validateRootFormMode", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootFormMode: { classPropertyName: "ngxValidateRootFormMode", publicName: "ngxValidateRootFormMode", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
3090
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3091
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: ValidateRootFormDirective, isStandalone: true, selector: "form[validateRootForm], form[ngxValidateRootForm]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null }, formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, validateRootForm: { classPropertyName: "validateRootForm", publicName: "validateRootForm", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootForm: { classPropertyName: "ngxValidateRootForm", publicName: "ngxValidateRootForm", isSignal: true, isRequired: false, transformFunction: null }, validateRootFormMode: { classPropertyName: "validateRootFormMode", publicName: "validateRootFormMode", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootFormMode: { classPropertyName: "ngxValidateRootFormMode", publicName: "ngxValidateRootFormMode", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2829
3092
  {
2830
3093
  provide: NG_ASYNC_VALIDATORS,
2831
3094
  useExisting: ValidateRootFormDirective,
@@ -2833,7 +3096,7 @@ class ValidateRootFormDirective {
2833
3096
  },
2834
3097
  ], ngImport: i0 }); }
2835
3098
  }
2836
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
3099
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
2837
3100
  type: Directive,
2838
3101
  args: [{
2839
3102
  selector: 'form[validateRootForm], form[ngxValidateRootForm]',
@@ -3693,5 +3956,5 @@ function keepFieldsWhen(currentState, conditions) {
3693
3956
  * Generated bundle index. Do not edit.
3694
3957
  */
3695
3958
 
3696
- export { ControlWrapperComponent, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeValuesAndRawValues, objectToArray, parseFieldPath, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
3959
+ export { ControlWrapperComponent, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NGX_WARNING_DISPLAY_MODE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeValuesAndRawValues, objectToArray, parseFieldPath, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
3697
3960
  //# sourceMappingURL=ngx-vest-forms.mjs.map