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