ngx-vest-forms 2.5.1 → 2.7.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, isDevMode, inject, ElementRef, DestroyRef, ChangeDetectorRef, signal, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
3
- import { isFormArray, isFormGroup, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, FormGroup, FormArray, NgModel, NgModelGroup, FormSubmittedEvent, FormResetEvent, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
2
+ import { InjectionToken, isDevMode, signal, inject, ElementRef, DestroyRef, ChangeDetectorRef, linkedSignal, computed, input, output, effect, untracked, Directive, contentChild, Injector, afterNextRender, afterEveryRender, isSignal, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
3
+ import { isFormArray, isFormGroup, NgForm, ValueChangeEvent, StatusChangeEvent, PristineChangeEvent, FormGroup, FormArray, NgModel, NgModelGroup, FormSubmittedEvent, FormResetEvent, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
4
4
  import { toSignal, outputFromObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
5
- import { startWith, merge, filter, map, switchMap, take, of, scan, distinctUntilChanged, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
5
+ import { filter, scan, startWith, merge, map, switchMap, take, of, distinctUntilChanged, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
6
6
 
7
7
  /**
8
8
  * @deprecated Use NGX_ERROR_DISPLAY_MODE_TOKEN instead
@@ -113,6 +113,20 @@ const NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN = new InjectionToken('NgxValidationCo
113
113
  function isPrimitive$1(value) {
114
114
  return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
115
115
  }
116
+ /**
117
+ * Records that we've started comparing the pair (a, b). Re-entering the same
118
+ * pair during recursion (cycle, or repeated DAG path) short-circuits to `true`
119
+ * — correct because if the prior descent had returned `false`, the outer call
120
+ * would already have short-circuited before we reach the second visit.
121
+ */
122
+ function rememberPair(seen, a, b) {
123
+ let targets = seen.get(a);
124
+ if (!targets) {
125
+ targets = new WeakSet();
126
+ seen.set(a, targets);
127
+ }
128
+ targets.add(b);
129
+ }
116
130
  /**
117
131
  * @internal
118
132
  * Internal utility for shallow equality checks.
@@ -207,11 +221,13 @@ function shallowEqual(obj1, obj2) {
207
221
  * - Plain objects (with recursive deep comparison)
208
222
  * - Date objects (by timestamp comparison)
209
223
  * - RegExp objects (by source and flags comparison)
210
- * - Set objects (by size and value membership)
211
- * - Map objects (by size and key-value pairs)
224
+ * - Set objects (reference equality only)
225
+ * - Map objects (reference equality only)
226
+ * - Functions (reference equality only — distinct function instances are never equal,
227
+ * even if their source code is identical)
212
228
  *
213
229
  * **Safety Features:**
214
- * - **Circular reference protection**: MaxDepth parameter prevents infinite recursion
230
+ * - **Circular reference handling**: Tracks visited object pairs with `WeakMap<object, WeakSet<object>>`
215
231
  * - **Type coercion prevention**: Strict type checking before comparison
216
232
  * - **Null safety**: Proper handling of null and undefined values
217
233
  *
@@ -223,7 +239,8 @@ function shallowEqual(obj1, obj2) {
223
239
  * ///
224
240
  * /// Memory usage:
225
241
  * /// JSON.stringify: Creates temporary strings (high GC pressure)
226
- * /// fastDeepEqual: Zero allocations during comparison
242
+ * /// fastDeepEqual: One small WeakMap of visited object pairs is allocated lazily on
243
+ * /// first nested-container descent; primitive-only comparisons allocate nothing.
227
244
  * ```
228
245
  *
229
246
  * **Typical Usage in Forms:**
@@ -239,91 +256,222 @@ function shallowEqual(obj1, obj2) {
239
256
  *
240
257
  * @param obj1 - First object to compare
241
258
  * @param obj2 - Second object to compare
242
- * @param maxDepth - Maximum recursion depth to prevent infinite loops (default: 10)
259
+ *
260
+ * Cyclic arrays and plain objects are compared structurally by tracking visited object
261
+ * pairs. Distinct cyclic graphs with the same structure compare equal. `Date` and
262
+ * `RegExp` values compare structurally. `Map` and `Set` values compare by reference
263
+ * only, so distinct instances are considered different even if their contents match.
264
+ *
243
265
  * @returns true if objects are deeply equal by value
244
266
  */
245
- function fastDeepEqual(obj1, obj2, maxDepth = 10) {
246
- if (maxDepth <= 0) {
247
- // Fallback to shallow comparison at max depth to prevent infinite recursion
248
- return obj1 === obj2;
249
- }
250
- if (obj1 === obj2) {
267
+ function fastDeepEqual(obj1, obj2) {
268
+ return fastDeepEqualInternal(obj1, obj2, undefined);
269
+ }
270
+ function fastDeepEqualInternal(obj1, obj2, seen) {
271
+ // Object.is gives correct semantics for NaN and ±0 — important for numeric
272
+ // form values where `fastDeepEqual(NaN, NaN)` should be true.
273
+ if (Object.is(obj1, obj2)) {
251
274
  return true;
252
275
  }
253
276
  if (obj1 == null || obj2 == null) {
254
- return obj1 === obj2;
277
+ return false;
255
278
  }
256
279
  if (typeof obj1 !== typeof obj2) {
257
280
  return false;
258
281
  }
282
+ // Functions use reference-only equality. Object.is at the top already returned
283
+ // true for identical references, so reaching here means the references differ.
284
+ if (typeof obj1 === 'function') {
285
+ return false;
286
+ }
259
287
  if (isPrimitive$1(obj1) || isPrimitive$1(obj2)) {
260
- return obj1 === obj2;
288
+ return false;
289
+ }
290
+ // Handle Date / RegExp first — they have value semantics and can't contain cycles,
291
+ // so they don't need pair tracking.
292
+ if (obj1 instanceof Date || obj2 instanceof Date) {
293
+ return (obj1 instanceof Date &&
294
+ obj2 instanceof Date &&
295
+ obj1.getTime() === obj2.getTime());
296
+ }
297
+ if (obj1 instanceof RegExp || obj2 instanceof RegExp) {
298
+ return (obj1 instanceof RegExp &&
299
+ obj2 instanceof RegExp &&
300
+ obj1.source === obj2.source &&
301
+ obj1.flags === obj2.flags);
302
+ }
303
+ // Intentional contract: distinct Set / Map instances compare by reference only.
304
+ if (obj1 instanceof Set || obj2 instanceof Set) {
305
+ return false;
306
+ }
307
+ if (obj1 instanceof Map || obj2 instanceof Map) {
308
+ return false;
261
309
  }
262
- // Handle arrays early for performance (common in forms)
263
310
  if (Array.isArray(obj1) !== Array.isArray(obj2)) {
264
311
  return false;
265
312
  }
313
+ // Cycle / repeated-pair guard for traversable containers.
314
+ // Allocates lazily on first nested-object descent.
315
+ const a = obj1;
316
+ const b = obj2;
317
+ if (seen?.get(a)?.has(b)) {
318
+ return true;
319
+ }
320
+ seen ??= new WeakMap();
321
+ rememberPair(seen, a, b);
266
322
  if (Array.isArray(obj1)) {
267
- // We know obj2 is also an array here
268
323
  const arr2 = obj2;
269
324
  if (obj1.length !== arr2.length) {
270
325
  return false;
271
326
  }
272
327
  for (let i = 0; i < obj1.length; i++) {
273
- if (!fastDeepEqual(obj1[i], arr2[i], maxDepth - 1)) {
328
+ if (!fastDeepEqualInternal(obj1[i], arr2[i], seen)) {
274
329
  return false;
275
330
  }
276
331
  }
277
332
  return true;
278
333
  }
279
- // Handle Date objects
280
- if (obj1 instanceof Date) {
281
- return obj2 instanceof Date && obj1.getTime() === obj2.getTime();
282
- }
283
- // Handle RegExp objects
284
- if (obj1 instanceof RegExp) {
285
- return (obj2 instanceof RegExp &&
286
- obj1.source === obj2.source &&
287
- obj1.flags === obj2.flags);
334
+ // Plain objects
335
+ const keys1 = Object.keys(a);
336
+ const o2 = b;
337
+ if (keys1.length !== Object.keys(o2).length) {
338
+ return false;
288
339
  }
289
- // Handle Set objects (common in forms)
290
- if (obj1 instanceof Set) {
291
- if (!(obj2 instanceof Set) || obj1.size !== obj2.size) {
340
+ for (const key of keys1) {
341
+ if (!Object.hasOwn(o2, key)) {
292
342
  return false;
293
343
  }
294
- for (const value of obj1) {
295
- if (!obj2.has(value)) {
296
- return false;
297
- }
344
+ if (!fastDeepEqualInternal(a[key], o2[key], seen)) {
345
+ return false;
298
346
  }
299
- return true;
300
347
  }
301
- // Handle Map objects (common in forms)
302
- if (obj1 instanceof Map) {
303
- if (!(obj2 instanceof Map) || obj1.size !== obj2.size) {
304
- return false;
348
+ return true;
349
+ }
350
+
351
+ /**
352
+ * Injection token for the deep-equality function used internally by
353
+ * {@link FormDirective} for change detection — `formValueChange`
354
+ * `distinctUntilChanged`, the form↔model two-way sync effect, and the
355
+ * `formState` signal's structural comparator.
356
+ *
357
+ * The default factory returns {@link fastDeepEqual}. Override this token to
358
+ * plug in a smaller or differently-tuned comparator (e.g. `dequal/lite`,
359
+ * `lodash.isEqual`) without forking the directive.
360
+ *
361
+ * @example Bring-your-own equality at the application level
362
+ * ```ts
363
+ * import { dequal } from 'dequal/lite';
364
+ * import { NGX_EQUALITY_FN } from 'ngx-vest-forms';
365
+ *
366
+ * export const appConfig: ApplicationConfig = {
367
+ * providers: [
368
+ * { provide: NGX_EQUALITY_FN, useValue: dequal },
369
+ * ],
370
+ * };
371
+ * ```
372
+ *
373
+ * @example Per-component override (e.g. for tests)
374
+ * ```ts
375
+ * @Component({
376
+ * providers: [
377
+ * { provide: NGX_EQUALITY_FN, useValue: (a, b) => a === b },
378
+ * ],
379
+ * })
380
+ * export class TestFormComponent {}
381
+ * ```
382
+ *
383
+ * @default {@link fastDeepEqual}
384
+ */
385
+ const NGX_EQUALITY_FN = new InjectionToken('NgxEqualityFn', {
386
+ providedIn: 'root',
387
+ factory: () => fastDeepEqual,
388
+ });
389
+
390
+ /**
391
+ * Schedules a callback to run after a delay, automatically cancelling it if the
392
+ * provided `DestroyRef` fires before the timer expires.
393
+ *
394
+ * @param callback - Function to invoke after the delay.
395
+ * @param delayMs - Delay in milliseconds (passed to `setTimeout`).
396
+ * @param destroyRef - Angular `DestroyRef` to register auto-cancellation against.
397
+ * @returns A cancel function; calling it before the timer fires prevents the
398
+ * callback from running and unregisters the destroy listener.
399
+ */
400
+ function scheduleTimeout(callback, delayMs, destroyRef) {
401
+ let cancelled = false;
402
+ let unregisterCalled = false;
403
+ // eslint-disable-next-line prefer-const -- assigned after onDestroy returns
404
+ let unregisterDestroy;
405
+ // Idempotent unregister wrapper so all paths (timer-fires, explicit-cancel,
406
+ // and onDestroy) can call it safely without double-removal.
407
+ const safeUnregister = () => {
408
+ if (unregisterCalled)
409
+ return;
410
+ unregisterCalled = true;
411
+ unregisterDestroy?.();
412
+ };
413
+ const handle = setTimeout(() => {
414
+ safeUnregister();
415
+ if (!cancelled) {
416
+ callback();
305
417
  }
306
- for (const [key, value] of obj1) {
307
- if (!obj2.has(key) ||
308
- !fastDeepEqual(value, obj2.get(key), maxDepth - 1)) {
309
- return false;
310
- }
418
+ }, delayMs);
419
+ unregisterDestroy = destroyRef.onDestroy(() => {
420
+ cancelled = true;
421
+ clearTimeout(handle);
422
+ safeUnregister();
423
+ });
424
+ return () => {
425
+ if (!cancelled) {
426
+ cancelled = true;
427
+ clearTimeout(handle);
428
+ safeUnregister();
311
429
  }
312
- return true;
313
- }
314
- // Handle plain objects
315
- const keys1 = Object.keys(obj1);
316
- const keys2 = Object.keys(obj2);
317
- if (keys1.length !== keys2.length) {
318
- return false;
430
+ };
431
+ }
432
+ /**
433
+ * Schedules a microtask callback, automatically suppressing it if the provided
434
+ * `DestroyRef` fires before the microtask runs.
435
+ *
436
+ * Since `queueMicrotask` has no native cancellation API, this relies on a
437
+ * destroyed flag that is set inside the registered `onDestroy` hook.
438
+ *
439
+ * @param callback - Function to invoke in the next microtask checkpoint.
440
+ * @param destroyRef - Angular `DestroyRef` to register auto-cancellation against.
441
+ * @returns A cancel function; calling it before the microtask runs prevents the
442
+ * callback from executing and unregisters the destroy listener.
443
+ */
444
+ function scheduleMicrotask(callback, destroyRef) {
445
+ let cancelled = false;
446
+ let unregisterCalled = false;
447
+ // Register the onDestroy listener before queuing the microtask so that
448
+ // `unregisterDestroy` is always defined when the microtask fires.
449
+ const unregisterDestroy = destroyRef.onDestroy(() => {
450
+ cancelled = true;
451
+ safeUnregister();
452
+ });
453
+ // Idempotent unregister wrapper so all paths (microtask-fires, explicit-cancel,
454
+ // and onDestroy) can call it safely without double-removal.
455
+ function safeUnregister() {
456
+ if (unregisterCalled)
457
+ return;
458
+ unregisterCalled = true;
459
+ unregisterDestroy();
319
460
  }
320
- for (const key of keys1) {
321
- if (!Object.hasOwn(obj2, key) ||
322
- !fastDeepEqual(obj1[key], obj2[key], maxDepth - 1)) {
323
- return false;
461
+ queueMicrotask(() => {
462
+ // Always clean up the destroy listener when the microtask fires,
463
+ // regardless of whether the callback is suppressed.
464
+ safeUnregister();
465
+ if (!cancelled) {
466
+ callback();
324
467
  }
325
- }
326
- return true;
468
+ });
469
+ return () => {
470
+ if (!cancelled) {
471
+ cancelled = true;
472
+ safeUnregister();
473
+ }
474
+ };
327
475
  }
328
476
 
329
477
  /**
@@ -345,6 +493,7 @@ function fastDeepEqual(obj1, obj2, maxDepth = 10) {
345
493
  * ```
346
494
  */
347
495
  const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
496
+ const LOG_PREFIX$1 = '[ngx-vest-forms] field-path.utils';
348
497
  /**
349
498
  * @internal
350
499
  * Returns whether a path segment is unsafe for object writes/merges.
@@ -391,11 +540,26 @@ function isUnsafePathSegment(segment) {
391
540
  function parseFieldPath(path) {
392
541
  if (!path)
393
542
  return [];
394
- return path
395
- .replaceAll(/\[(\d+)\]/g, '.$1') // Convert brackets to dots: items[0] → items.0
396
- .split('.') // Split by dots
397
- .filter((part) => part !== '') // Remove empty strings from leading brackets
398
- .map((part) => (/^\d+$/.test(part) ? Number(part) : part)); // Convert numeric strings to numbers
543
+ // Normalize bracket notation to dot notation first so malformed inputs like
544
+ // 'a.[0]' (which becomes 'a..0') are caught alongside 'a..b', '.a', 'a.', '.'.
545
+ const startsWithBracket = path.startsWith('[');
546
+ const segments = path
547
+ .replaceAll(/\[(\d+)\]/g, '.$1')
548
+ .split('.');
549
+ // Empty segments after normalization signal a malformed path. The single
550
+ // legitimate case is a leading empty produced by a path that originally
551
+ // started with '[' (e.g. '[0].x' → '.0.x' → ['', '0', 'x']).
552
+ for (let i = 0; i < segments.length; i++) {
553
+ if (segments[i] === '' && !(i === 0 && startsWithBracket)) {
554
+ if (typeof ngDevMode !== 'undefined' && ngDevMode) {
555
+ console.warn(`${LOG_PREFIX$1}: Invalid field path '${path}'. Leading dots, trailing dots, consecutive dots, and '.[' separators are not allowed.`);
556
+ }
557
+ return [];
558
+ }
559
+ }
560
+ return segments
561
+ .filter((part) => part !== '')
562
+ .map((part) => (/^\d+$/.test(part) ? Number(part) : part));
399
563
  }
400
564
  /**
401
565
  * Converts a Standard Schema path array (e.g. `['addresses', 0, 'street']`)
@@ -654,8 +818,9 @@ function mergeValuesAndRawValues(form) {
654
818
  }
655
819
  const sourceValue = source[key];
656
820
  const targetValue = target[key];
657
- if (targetValue === undefined) {
658
- // If the key is not in the target, add it directly (for disabled fields)
821
+ if (targetValue === undefined || targetValue === null) {
822
+ // Key missing from target (e.g. disabled field) or set to null
823
+ // copy source so raw values from disabled controls aren't dropped.
659
824
  target[key] = sourceValue;
660
825
  }
661
826
  else if (isRecord(sourceValue) && isRecord(targetValue)) {
@@ -678,15 +843,30 @@ function getStringArrayError(errors, key) {
678
843
  : undefined;
679
844
  }
680
845
  /**
681
- * Performs a deep-clone of an object
682
- * @param obj
846
+ * Performs a deep-clone of an object.
847
+ *
848
+ * @deprecated Use the standard {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone structuredClone} instead.
683
849
  *
684
- * @deprecated Use official ES {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone structuredClone} instead
850
+ * `structuredClone` correctly handles `Map`, `Set`, `RegExp`, typed arrays, and
851
+ * cyclic references; this implementation silently drops `Map` / `Set` / `RegExp`
852
+ * data and produces incorrect results on cycles. Scheduled for removal in a
853
+ * future major; see `docs/prd/PRD-bug-sweep.md` (Bundle D) for tracking.
685
854
  *
686
- * Browser Support: structuredClone is available in all modern browsers (Chrome 98+, Firefox 94+, Safari 15.4+, Edge 98+)
687
- * and Node.js 17+. A polyfill is provided in test-setup.ts for Jest test environments.
855
+ * Browser Support: `structuredClone` is available in all modern browsers
856
+ * (Chrome 98+, Firefox 94+, Safari 15.4+, Edge 98+) and Node.js 17+.
688
857
  */
858
+ let cloneDeepDeprecationWarned = false;
689
859
  function cloneDeep(object) {
860
+ // NOTE: `typeof ngDevMode !== 'undefined' && ngDevMode` is kept inline
861
+ // (not extracted to a helper) because Angular's build optimizer relies on
862
+ // this exact pattern for tree-shaking dev-only code from production bundles.
863
+ if (!cloneDeepDeprecationWarned &&
864
+ typeof ngDevMode !== 'undefined' &&
865
+ ngDevMode) {
866
+ cloneDeepDeprecationWarned = true;
867
+ console.warn('[ngx-vest-forms] cloneDeep is deprecated and silently drops Map/Set/RegExp values. ' +
868
+ 'Use the standard structuredClone() instead.');
869
+ }
690
870
  // Handle primitives (null, undefined, boolean, string, number, function)
691
871
  if (isPrimitive(object)) {
692
872
  return object;
@@ -732,6 +912,7 @@ function setValueAtPath(obj, path, value) {
732
912
  let current = obj;
733
913
  for (let i = 0; i < keys.length - 1; i++) {
734
914
  const segment = keys[i];
915
+ const nextSegment = keys[i + 1];
735
916
  if (segment === undefined) {
736
917
  continue;
737
918
  }
@@ -740,8 +921,10 @@ function setValueAtPath(obj, path, value) {
740
921
  }
741
922
  const key = String(segment);
742
923
  const next = current[key];
743
- if (!isRecord(next)) {
744
- current[key] = {};
924
+ if (!Array.isArray(next) && !isRecord(next)) {
925
+ const shouldCreateArray = typeof nextSegment === 'number' ||
926
+ (typeof nextSegment === 'string' && /^\d+$/.test(nextSegment));
927
+ current[key] = shouldCreateArray ? [] : {};
745
928
  }
746
929
  current = current[key];
747
930
  }
@@ -836,6 +1019,18 @@ function getAllFormErrors(form) {
836
1019
  return errors;
837
1020
  }
838
1021
 
1022
+ const NUMERIC_PATH_SEGMENT = /^\d+$/;
1023
+ function isOpaqueLeafValue(value) {
1024
+ return (value instanceof Date ||
1025
+ value instanceof Map ||
1026
+ value instanceof Set ||
1027
+ value instanceof RegExp ||
1028
+ (typeof File !== 'undefined' && value instanceof File) ||
1029
+ (typeof Blob !== 'undefined' && value instanceof Blob));
1030
+ }
1031
+ function isTraversableValue(value) {
1032
+ return typeof value === 'object' && value !== null && !isOpaqueLeafValue(value);
1033
+ }
839
1034
  /**
840
1035
  * Validates a form value against a shape to catch typos in `name` or `ngModelGroup` attributes.
841
1036
  *
@@ -871,31 +1066,86 @@ function validateFormValueAgainstShape(formValue, shape, path = '') {
871
1066
  }
872
1067
  // For array items (numeric keys > 0), compare against the first item in shape
873
1068
  // since we only define one example item in the shape for arrays
874
- const isNumericKey = !isNaN(parseFloat(key));
875
- const shapeKey = isNumericKey && parseFloat(key) > 0 ? '0' : key;
1069
+ const isNumericKey = NUMERIC_PATH_SEGMENT.test(key);
1070
+ // Array shapes provide one example item at index 0, so every numeric key maps to '0'.
1071
+ const shapeKey = isNumericKey && key !== '0' ? '0' : key;
876
1072
  const shapeValue = shape?.[shapeKey];
1073
+ const hasShapeKey = shape != null && shapeKey in shape;
877
1074
  // Skip Date fields receiving empty strings (common in date picker libraries)
878
1075
  if (shapeValue instanceof Date && value === '') {
879
1076
  continue;
880
1077
  }
881
1078
  // Handle object values (recurse into nested objects)
882
1079
  if (typeof value === 'object') {
1080
+ if (!isTraversableValue(value)) {
1081
+ if (!isNumericKey && !hasShapeKey) {
1082
+ logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
1083
+ }
1084
+ else if (!isNumericKey &&
1085
+ hasShapeKey &&
1086
+ (shapeValue === null || typeof shapeValue !== 'object')) {
1087
+ // Type mismatch: formValue holds an opaque object (Date, Map, etc.)
1088
+ // but shape declares a primitive. Recursion is intentionally skipped
1089
+ // for opaque leaves, but the user still benefits from a warning.
1090
+ logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
1091
+ }
1092
+ continue;
1093
+ }
883
1094
  // Type mismatch: formValue has object, but shape expects primitive
884
- if (!isNumericKey &&
885
- (typeof shapeValue !== 'object' || shapeValue === null)) {
1095
+ if (!isNumericKey && !isTraversableValue(shapeValue)) {
886
1096
  logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
887
1097
  }
888
1098
  // Recurse into nested object
889
- validateFormValueAgainstShape(value, shapeValue ?? {}, fieldPath);
1099
+ validateFormValueAgainstShape(value, isTraversableValue(shapeValue) ? shapeValue : {}, fieldPath);
890
1100
  continue;
891
1101
  }
892
1102
  // Extra property: key exists in formValue but not in shape (likely a typo)
893
- if (!isNumericKey && shape && !(shapeKey in shape)) {
1103
+ if (!isNumericKey && !hasShapeKey) {
894
1104
  logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
895
1105
  }
896
1106
  }
897
1107
  }
898
1108
 
1109
+ const formSubmittedSignals = new WeakMap();
1110
+ function getFormSubmittedSignal(ngForm) {
1111
+ let submitted = formSubmittedSignals.get(ngForm);
1112
+ if (!submitted) {
1113
+ submitted = signal(ngForm.submitted);
1114
+ formSubmittedSignals.set(ngForm, submitted);
1115
+ }
1116
+ return submitted;
1117
+ }
1118
+ function setAngularFormSubmittedState(ngForm, submitted) {
1119
+ // Angular 21.x's concrete NgForm stores submitted state on
1120
+ // `submittedReactive`, while AbstractFormDirective-backed implementations
1121
+ // expose `_submittedReactive` and may also provide a public setter on
1122
+ // `submitted`. This helper is verified against Angular 21.x in this
1123
+ // repository and falls back to the first writable `submitted` setter it can
1124
+ // find if a future Angular version changes the concrete field names.
1125
+ //
1126
+ // We try the concrete/internal signal first because NgForm overrides the
1127
+ // getter-only `submitted` property at runtime in this workspace. If neither
1128
+ // signal exists, fall back to the first writable `submitted` setter we can
1129
+ // find on the prototype chain. If no setter exists either, callers should
1130
+ // still update ngx-vest-forms' shared signal so error display state remains
1131
+ // reactive even though Angular's native submitted flag cannot be changed.
1132
+ const signalHost = ngForm;
1133
+ const angularSignal = signalHost.submittedReactive ?? signalHost._submittedReactive;
1134
+ if (angularSignal) {
1135
+ angularSignal.set(submitted);
1136
+ return;
1137
+ }
1138
+ let prototype = Object.getPrototypeOf(ngForm);
1139
+ while (prototype) {
1140
+ const submittedDescriptor = Object.getOwnPropertyDescriptor(prototype, 'submitted');
1141
+ if (submittedDescriptor?.set) {
1142
+ submittedDescriptor.set.call(ngForm, submitted);
1143
+ return;
1144
+ }
1145
+ prototype = Object.getPrototypeOf(prototype);
1146
+ }
1147
+ }
1148
+
899
1149
  /**
900
1150
  * Duration (in milliseconds) to keep fields marked as "in-progress" after validation.
901
1151
  * This prevents immediate re-triggering of bidirectional validations.
@@ -941,12 +1191,22 @@ const VALIDATION_IN_PROGRESS_TIMEOUT_MS = 500;
941
1191
  * @publicApi
942
1192
  */
943
1193
  class FormDirective {
944
- // Track last linked value to prevent unnecessary updates
945
- #lastLinkedValue;
1194
+ /**
1195
+ * Deep-equality comparator. Defaults to `fastDeepEqual`; can be overridden
1196
+ * application-wide or per-component via {@link NGX_EQUALITY_FN}.
1197
+ */
1198
+ #equal;
1199
+ /**
1200
+ * Set to true by the onDestroy hook. Used to guard async callbacks
1201
+ * (e.g. Vest `done()`) that cannot be cancelled via RxJS operators.
1202
+ */
1203
+ #destroyed;
946
1204
  #lastSyncedFormValue;
947
1205
  #lastSyncedModelValue;
948
- // Internal signal tracking form value changes via statusChanges
949
- #value;
1206
+ // Internal signal tracking changes that can affect the merged form snapshot.
1207
+ // ValueChangeEvent keeps the cache fresh for blur-driven consumers like
1208
+ // draft auto-save, even when a value update doesn't change form validity.
1209
+ #formSnapshotTick;
950
1210
  /**
951
1211
  * LinkedSignal that computes form values from Angular form state.
952
1212
  * This eliminates timing issues with the previous dual-effect pattern.
@@ -973,36 +1233,43 @@ class FormDirective {
973
1233
  this.destroyRef = inject(DestroyRef);
974
1234
  this.cdr = inject(ChangeDetectorRef);
975
1235
  this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
1236
+ /**
1237
+ * Deep-equality comparator. Defaults to `fastDeepEqual`; can be overridden
1238
+ * application-wide or per-component via {@link NGX_EQUALITY_FN}.
1239
+ */
1240
+ this.#equal = inject(NGX_EQUALITY_FN);
976
1241
  /**
977
1242
  * Public signal storing field warnings keyed by field path.
978
1243
  * This allows warnings to be stored and displayed without affecting field validity.
979
1244
  * Angular's control.errors !== null marks a field as invalid, so we store warnings
980
1245
  * separately when they exist without errors.
981
1246
  */
982
- this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : []));
983
- // Track last linked value to prevent unnecessary updates
984
- this.#lastLinkedValue = null;
1247
+ this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : /* istanbul ignore next */ []));
1248
+ /**
1249
+ * Set to true by the onDestroy hook. Used to guard async callbacks
1250
+ * (e.g. Vest `done()`) that cannot be cancelled via RxJS operators.
1251
+ */
1252
+ this.#destroyed = false;
985
1253
  this.#lastSyncedFormValue = null;
986
1254
  this.#lastSyncedModelValue = null;
987
- // Internal signal tracking form value changes via statusChanges
988
- this.#value = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
1255
+ // Internal signal tracking changes that can affect the merged form snapshot.
1256
+ // ValueChangeEvent keeps the cache fresh for blur-driven consumers like
1257
+ // draft auto-save, even when a value update doesn't change form validity.
1258
+ this.#formSnapshotTick = toSignal(this.ngForm.form.events.pipe(filter((event) => event instanceof ValueChangeEvent || event instanceof StatusChangeEvent), scan((count) => count + 1, 0), startWith(0)), { initialValue: 0 });
989
1259
  /**
990
1260
  * LinkedSignal that computes form values from Angular form state.
991
1261
  * This eliminates timing issues with the previous dual-effect pattern.
992
1262
  */
993
1263
  this.#formValueSignal = linkedSignal(() => {
994
- // Track form value changes
995
- this.#value();
996
- const raw = mergeValuesAndRawValues(this.ngForm.form);
997
- if (Object.keys(this.ngForm.form.controls).length > 0) {
998
- this.#lastLinkedValue = raw;
999
- return raw;
1000
- }
1001
- else if (this.#lastLinkedValue !== null) {
1002
- return this.#lastLinkedValue;
1264
+ // Track changes that affect the merged form snapshot.
1265
+ this.#formSnapshotTick();
1266
+ if (Object.keys(this.ngForm.form.controls).length === 0) {
1267
+ // No controls remain (e.g. dynamic group removal): expose `null` so
1268
+ // consumers don't see ghost data from a previous form shape.
1269
+ return null;
1003
1270
  }
1004
- return null;
1005
- }, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : []));
1271
+ return mergeValuesAndRawValues(this.ngForm.form);
1272
+ }, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : /* istanbul ignore next */ []));
1006
1273
  /**
1007
1274
  * Track the Angular form status as a signal for advanced status flags
1008
1275
  */
@@ -1012,7 +1279,7 @@ class FormDirective {
1012
1279
  * This guarantees recomputation for every blur/tab interaction,
1013
1280
  * even when the form's aggregate touched flag is already true.
1014
1281
  */
1015
- this.#blurTick = signal(0, ...(ngDevMode ? [{ debugName: "#blurTick" }] : []));
1282
+ this.#blurTick = signal(0, ...(ngDevMode ? [{ debugName: "#blurTick" }] : /* istanbul ignore next */ []));
1016
1283
  /**
1017
1284
  * Computed signal that returns field paths for all touched (or submitted) leaf controls.
1018
1285
  * Updates reactively when controls are touched (blur) or when form status changes.
@@ -1026,7 +1293,7 @@ class FormDirective {
1026
1293
  this.#blurTick();
1027
1294
  this.#statusSignal();
1028
1295
  return this.#collectTouchedPaths(this.ngForm.form, this.ngForm.submitted);
1029
- }, ...(ngDevMode ? [{ debugName: "touchedFieldPaths" }] : []));
1296
+ }, ...(ngDevMode ? [{ debugName: "touchedFieldPaths" }] : /* istanbul ignore next */ []));
1030
1297
  /**
1031
1298
  * Computed signal for form state with validity and errors.
1032
1299
  * Used by templates and tests as vestForm.formState().valid/errors
@@ -1043,7 +1310,7 @@ class FormDirective {
1043
1310
  errors: getAllFormErrors(this.ngForm.form),
1044
1311
  value: this.#formValueSignal(),
1045
1312
  };
1046
- }, { ...(ngDevMode ? { debugName: "formState" } : {}), equal: (a, b) => {
1313
+ }, { ...(ngDevMode ? { debugName: "formState" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1047
1314
  // Fast path: reference equality
1048
1315
  if (a === b)
1049
1316
  return true;
@@ -1052,29 +1319,29 @@ class FormDirective {
1052
1319
  return false;
1053
1320
  // Deep equality check for form state properties
1054
1321
  return (a.valid === b.valid &&
1055
- fastDeepEqual(a.errors, b.errors) &&
1056
- fastDeepEqual(a.value, b.value));
1322
+ this.#equal(a.errors, b.errors) &&
1323
+ this.#equal(a.value, b.value));
1057
1324
  } });
1058
1325
  /**
1059
1326
  * The value of the form, this is needed for the validation part.
1060
1327
  * Using input() here because two-way binding is provided via formValueChange output.
1061
1328
  * In the minimal core directive (form-core.directive.ts), this would be model() instead.
1062
1329
  */
1063
- this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
1330
+ this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : /* istanbul ignore next */ []));
1064
1331
  /**
1065
1332
  * Static vest suite that will be used to feed our angular validators.
1066
1333
  * Accepts both NgxVestSuite and NgxTypedVestSuite through compatible type signatures.
1067
1334
  * NgxTypedVestSuite<T> is assignable to NgxVestSuite<T> due to bivariance and
1068
1335
  * FormFieldName<T> (string literal union) being assignable to string.
1069
1336
  */
1070
- this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
1337
+ this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : /* istanbul ignore next */ []));
1071
1338
  /**
1072
1339
  * The shape of our form model. This is a deep required version of the form model
1073
1340
  * The goal is to add default values to the shape so when the template-driven form
1074
1341
  * contains values that shouldn't be there (typo's) that the developer gets run-time
1075
1342
  * errors in dev mode
1076
1343
  */
1077
- this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : []));
1344
+ this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : /* istanbul ignore next */ []));
1078
1345
  /**
1079
1346
  * Updates the validation config which is a dynamic object that will be used to
1080
1347
  * trigger validations on the dependant fields
@@ -1088,7 +1355,7 @@ class FormDirective {
1088
1355
  *
1089
1356
  * @param v
1090
1357
  */
1091
- this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : []));
1358
+ this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : /* istanbul ignore next */ []));
1092
1359
  /**
1093
1360
  * Emits whenever validation feedback may have changed, even if the aggregate
1094
1361
  * root form status string stays the same.
@@ -1119,7 +1386,7 @@ class FormDirective {
1119
1386
  */
1120
1387
  this.formValueChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof ValueChangeEvent), map((v) => v.value), distinctUntilChanged((prev, curr) => {
1121
1388
  // Use efficient deep equality instead of JSON.stringify for better performance
1122
- return fastDeepEqual(prev, curr);
1389
+ return this.#equal(prev, curr);
1123
1390
  }), map(() => mergeValuesAndRawValues(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
1124
1391
  /**
1125
1392
  * Emits an object with all the errors of the form
@@ -1147,11 +1414,18 @@ class FormDirective {
1147
1414
  * Cleanup is handled automatically by the directive when it's destroyed.
1148
1415
  */
1149
1416
  this.validChange = outputFromObservable(this.statusChanges$.pipe(filter((e) => e === 'VALID' || e === 'INVALID'), map((v) => v === 'VALID'), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
1417
+ /**
1418
+ * Emits when a named control inside the form loses focus.
1419
+ *
1420
+ * Useful for application-level workflows such as draft auto-save on blur.
1421
+ */
1422
+ this.fieldBlur = output();
1150
1423
  /**
1151
1424
  * Track validation in progress to prevent circular triggering (Issue #19)
1152
1425
  */
1153
1426
  this.validationInProgress = new Set();
1154
1427
  this.destroyRef.onDestroy(() => {
1428
+ this.#destroyed = true;
1155
1429
  this.fieldWarnings.set(new Map());
1156
1430
  });
1157
1431
  /**
@@ -1187,8 +1461,8 @@ class FormDirective {
1187
1461
  if (!formValue && !modelValue)
1188
1462
  return;
1189
1463
  // Compute change flags first
1190
- const formChanged = !fastDeepEqual(formValue, this.#lastSyncedFormValue);
1191
- const modelChanged = !fastDeepEqual(modelValue, this.#lastSyncedModelValue);
1464
+ const formChanged = !this.#equal(formValue, this.#lastSyncedFormValue);
1465
+ const modelChanged = !this.#equal(modelValue, this.#lastSyncedModelValue);
1192
1466
  // Early return if nothing changed
1193
1467
  if (!formChanged && !modelChanged) {
1194
1468
  return;
@@ -1222,7 +1496,7 @@ class FormDirective {
1222
1496
  else if (formChanged && modelChanged) {
1223
1497
  // Both form and model changed simultaneously
1224
1498
  // Check if they changed to the same value (synchronized change) or different values (conflict)
1225
- const valuesEqual = fastDeepEqual(formValue, modelValue);
1499
+ const valuesEqual = this.#equal(formValue, modelValue);
1226
1500
  if (valuesEqual) {
1227
1501
  // Both changed to the same value - this is a synchronized change, not a conflict
1228
1502
  // Just update tracking to acknowledge the change
@@ -1364,6 +1638,52 @@ class FormDirective {
1364
1638
  this.ngForm.form.markAllAsTouched();
1365
1639
  this.#blurTick.update((v) => v + 1);
1366
1640
  }
1641
+ /**
1642
+ * Clears the current submit cycle without resetting control values or metadata.
1643
+ *
1644
+ * Unlike {@link resetForm}, this only flips the submitted gate back to `false`.
1645
+ * Touched/dirty/pristine state is preserved so consumers can end `'on-submit'`
1646
+ * error visibility without a full form reset.
1647
+ *
1648
+ * **When to use:**
1649
+ * - You use submit-gated error visibility such as `'on-submit'`
1650
+ * - A submit attempt already happened
1651
+ * - The user resolved the current submit-time errors
1652
+ * - You want future untouched fields to wait for the next submit before showing errors
1653
+ *
1654
+ * **Why this exists:**
1655
+ * `resetForm()` would also clear touched/dirty/pristine metadata, which is often
1656
+ * too disruptive for long-form, multi-form, or mixed error-display flows.
1657
+ *
1658
+ * **What it does NOT do:**
1659
+ * - Does not change field values
1660
+ * - Does not mark controls pristine or untouched
1661
+ * - Does not re-run validation
1662
+ *
1663
+ * @example
1664
+ * ```typescript
1665
+ * submitAll(): void {
1666
+ * for (const form of this.submitForms()) {
1667
+ * form.ngForm.onSubmit(new Event('submit'));
1668
+ * }
1669
+ *
1670
+ * if (this.submitForms().every((form) => form.formState().valid)) {
1671
+ * for (const form of this.submitForms()) {
1672
+ * form.clearSubmittedState();
1673
+ * }
1674
+ * }
1675
+ * }
1676
+ * ```
1677
+ *
1678
+ * @see {@link resetForm} to fully reset values and control metadata
1679
+ * @see {@link markAllAsTouched} to manually show all errors
1680
+ * @see {@link triggerFormValidation} to re-run validation after structure changes
1681
+ */
1682
+ clearSubmittedState() {
1683
+ setAngularFormSubmittedState(this.ngForm, false);
1684
+ getFormSubmittedSignal(this.ngForm).set(false);
1685
+ this.#blurTick.update((v) => v + 1);
1686
+ }
1367
1687
  /**
1368
1688
  * Finds the first invalid element in this form, scrolls it into view, and focuses it.
1369
1689
  *
@@ -1408,13 +1728,98 @@ class FormDirective {
1408
1728
  * Host handler: called whenever any descendant field loses focus.
1409
1729
  * Used to make touched-path tracking react immediately on blur/tab.
1410
1730
  */
1411
- onFormFocusOut() {
1731
+ onFormFocusOut(event) {
1412
1732
  // Run on the next microtask to ensure Angular has already applied
1413
1733
  // control.touched changes for the field that just blurred.
1414
- queueMicrotask(() => {
1734
+ scheduleMicrotask(() => {
1415
1735
  this.#blurTick.update((v) => v + 1);
1736
+ this.#emitFieldBlurEvent(event);
1737
+ }, this.destroyRef);
1738
+ }
1739
+ #emitFieldBlurEvent(event) {
1740
+ const resolved = this.#resolveFieldFromFocusEvent(event);
1741
+ if (!resolved) {
1742
+ return;
1743
+ }
1744
+ const { field, control, element } = resolved;
1745
+ // Read the latest value directly from the DOM element when it can be
1746
+ // trusted. For radio groups Angular keeps the bound `control.value` in
1747
+ // sync with the *selected* option, while a focused-but-unchecked radio
1748
+ // would expose its own option value via the DOM — so radios must always
1749
+ // fall back to `control.value`. For text/textarea/select we prefer the
1750
+ // element value to avoid `ngModelOptions.updateOn: 'submit'` staleness.
1751
+ const domValue = readElementValueForBlur(element);
1752
+ const value = domValue !== undefined ? domValue : control.value;
1753
+ // Prefer the cached linked-signal snapshot when it exists. Both code
1754
+ // paths produce a deep-cloned snapshot, but reusing the cached value
1755
+ // saves one of the two `structuredClone` passes performed by
1756
+ // `mergeValuesAndRawValues()` on every blur.
1757
+ const cachedSnapshot = this.#formValueSignal();
1758
+ const formValue = cachedSnapshot !== null
1759
+ ? structuredClone(cachedSnapshot)
1760
+ : mergeValuesAndRawValues(this.ngForm.form);
1761
+ setValueAtPath(formValue, field, value);
1762
+ this.fieldBlur.emit({
1763
+ field,
1764
+ value,
1765
+ formValue,
1766
+ dirty: control.dirty,
1767
+ touched: control.touched,
1768
+ valid: control.valid,
1769
+ pending: control.pending,
1416
1770
  });
1417
1771
  }
1772
+ #resolveFieldFromFocusEvent(event) {
1773
+ const target = event.target;
1774
+ if (!(target instanceof Element)) {
1775
+ return null;
1776
+ }
1777
+ const fieldElement = target.closest('[name]');
1778
+ if (!(fieldElement instanceof HTMLElement)) {
1779
+ return null;
1780
+ }
1781
+ const name = fieldElement.getAttribute('name')?.trim();
1782
+ if (!name) {
1783
+ return null;
1784
+ }
1785
+ const formEl = this.elementRef.nativeElement;
1786
+ // Authoritative path: ask the registered NgModel directive whose value
1787
+ // accessor is bound to this exact element. This handles all forms of
1788
+ // grouping uniformly — static `ngModelGroup="key"`, dynamic
1789
+ // `[ngModelGroup]="expr"`, repeated leaf names across siblings — because
1790
+ // the directive's `path` is computed from the live ControlContainer tree.
1791
+ const directiveMatch = resolveControlPathByNgModelDirective(this.ngForm, fieldElement);
1792
+ if (directiveMatch) {
1793
+ return {
1794
+ field: directiveMatch.path,
1795
+ control: directiveMatch.control,
1796
+ element: fieldElement,
1797
+ };
1798
+ }
1799
+ // Fallback: walk DOM ancestors collecting any `ngModelGroup` attribute
1800
+ // values, producing the canonical dotted path for the static attribute
1801
+ // form (e.g. `<div ngModelGroup="passwords"><input name="password">` →
1802
+ // `passwords.password`). Used when the directive lookup misses (e.g. the
1803
+ // value accessor doesn't expose its element ref in some custom CVAs).
1804
+ const staticGroups = collectNgModelGroupAttributes(fieldElement, formEl);
1805
+ const staticPath = [...staticGroups, name].join('.');
1806
+ const staticControl = this.ngForm.form.get(staticPath);
1807
+ if (staticControl) {
1808
+ return { field: staticPath, control: staticControl, element: fieldElement };
1809
+ }
1810
+ // Last-resort fallback for ambiguous DOM structures: probe each ancestor
1811
+ // element as a potential group boundary, querying the form tree until we
1812
+ // find a child that owns this DOM element.
1813
+ const dynamicMatch = resolveControlPathByDomAncestors(this.ngForm.form, fieldElement, formEl, name);
1814
+ if (dynamicMatch) {
1815
+ return {
1816
+ field: dynamicMatch.path,
1817
+ control: dynamicMatch.control,
1818
+ element: fieldElement,
1819
+ };
1820
+ }
1821
+ return null;
1822
+ }
1418
1823
  /**
1419
1824
  * Resets the form to a pristine, untouched state with optional new values.
1420
1825
  *
@@ -1475,7 +1880,6 @@ class FormDirective {
1475
1880
  // is treated as a model change (not a conflict with stale form values)
1476
1881
  this.#lastSyncedFormValue = null;
1477
1882
  this.#lastSyncedModelValue = null;
1478
- this.#lastLinkedValue = null;
1479
1883
  // Force change detection to ensure DOM updates are reflected
1480
1884
  // Note: This is still needed even with signals because we're modifying NgForm
1481
1885
  // (reactive forms), not signals. The formValue signal updates happen in the
@@ -1516,6 +1920,18 @@ class FormDirective {
1516
1920
  // Both NgxVestSuite and NgxTypedVestSuite work with string at runtime
1517
1921
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1518
1922
  suite(snap, field).done((result) => {
1923
+ // Guard: bail out if the directive was destroyed while
1924
+ // validation was in flight to avoid writing to disposed
1925
+ // signals or a torn-down view.
1926
+ if (this.#destroyed) {
1927
+ // Emit a neutral `null` before completing so async
1928
+ // validators always emit exactly once. Completing without
1929
+ // emission can leave consumers (e.g. a control's status)
1930
+ // in an unexpected `PENDING` state.
1931
+ observer.next(null);
1932
+ observer.complete();
1933
+ return;
1934
+ }
1519
1935
  const errors = result.getErrors()[field];
1520
1936
  const warnings = result.getWarnings()[field];
1521
1937
  // Store warnings in the fieldWarnings signal for access by control wrappers.
@@ -1552,8 +1968,9 @@ class FormDirective {
1552
1968
  // visual state (even though the control status has updated).
1553
1969
  //
1554
1970
  // We schedule a detectChanges() on the next microtask to avoid calling it
1555
- // synchronously inside Angular's own validation pipeline.
1556
- queueMicrotask(() => {
1971
+ // synchronously inside Angular's own validation pipeline. The scheduleMicrotask
1972
+ // primitive auto-cancels if the directive is destroyed before it fires.
1973
+ scheduleMicrotask(() => {
1557
1974
  try {
1558
1975
  this.cdr.detectChanges();
1559
1976
  }
@@ -1562,7 +1979,7 @@ class FormDirective {
1562
1979
  // This keeps behavior resilient in edge cases.
1563
1980
  this.cdr.markForCheck();
1564
1981
  }
1565
- });
1982
+ }, this.destroyRef);
1566
1983
  observer.next(out);
1567
1984
  observer.complete();
1568
1985
  });
@@ -1601,10 +2018,7 @@ class FormDirective {
1601
2018
  this.validationInProgress.clear();
1602
2019
  return EMPTY;
1603
2020
  }
1604
- const streams = Object.keys(config).map((triggerField) => {
1605
- const dependents = config[triggerField] || [];
1606
- return this.#createTriggerStream(form, triggerField, dependents);
1607
- });
2021
+ const streams = Object.entries(config).map(([triggerField, dependents]) => this.#createTriggerStream(form, triggerField, dependents || []));
1608
2022
  return streams.length > 0 ? merge(...streams) : EMPTY;
1609
2023
  }
1610
2024
  /**
@@ -1719,20 +2133,6 @@ class FormDirective {
1719
2133
  // CRITICAL: Mark the dependent field as in-progress BEFORE calling updateValueAndValidity
1720
2134
  // This prevents the dependent field's valueChanges from triggering its own validationConfig
1721
2135
  this.validationInProgress.add(depField);
1722
- // NOTE: Touch propagation removed (PR #78)
1723
- // Previously, we propagated touch state from trigger to dependent fields.
1724
- // This caused UX issues where dependent fields showed errors immediately
1725
- // after being revealed by a toggle, even though the user never interacted with them.
1726
- //
1727
- // With this change:
1728
- // - Errors on dependent fields only show after the user directly touches/blurs them
1729
- // - ARIA attributes (aria-invalid) still work correctly via isInvalid check
1730
- // - Warnings still show after validation via hasBeenValidated check
1731
- //
1732
- // The removed code was:
1733
- // if (control.touched && !dependentControl.touched) {
1734
- // dependentControl.markAsTouched({ onlySelf: true });
1735
- // }
1736
2136
  // emitEvent: true is REQUIRED for async validators to actually run
1737
2137
  // The validationInProgress Set prevents infinite loops:
1738
2138
  // 1. Field A changes → triggers validation on dependent field B
@@ -1756,13 +2156,14 @@ class FormDirective {
1756
2156
  }
1757
2157
  }
1758
2158
  // Keep fields marked as in-progress for a short time to prevent immediate re-triggering
1759
- // Use setTimeout to ensure async validators have time to complete before allowing new triggers
1760
- setTimeout(() => {
2159
+ // Use scheduleTimeout to ensure async validators have time to complete before allowing
2160
+ // new triggers. The timer auto-cancels on directive destroy so no timers leak.
2161
+ scheduleTimeout(() => {
1761
2162
  this.validationInProgress.delete(triggerField);
1762
2163
  for (const depField of dependents) {
1763
2164
  this.validationInProgress.delete(depField);
1764
2165
  }
1765
- }, VALIDATION_IN_PROGRESS_TIMEOUT_MS);
2166
+ }, VALIDATION_IN_PROGRESS_TIMEOUT_MS, this.destroyRef);
1766
2167
  }
1767
2168
  /**
1768
2169
  * Collects field paths of all touched (or submitted) leaf controls
@@ -1790,19 +2191,180 @@ class FormDirective {
1790
2191
  collect(control, []);
1791
2192
  return fields;
1792
2193
  }
1793
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1794
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.3", 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" }, host: { listeners: { "focusout": "onFormFocusOut()" } }, exportAs: ["scVestForm", "ngxVestForm"], ngImport: i0 }); }
2194
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2195
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", 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", fieldBlur: "fieldBlur" }, host: { listeners: { "focusout": "onFormFocusOut($event)" } }, exportAs: ["scVestForm", "ngxVestForm"], ngImport: i0 }); }
1795
2196
  }
1796
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormDirective, decorators: [{
2197
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormDirective, decorators: [{
1797
2198
  type: Directive,
1798
2199
  args: [{
1799
2200
  selector: 'form[scVestForm], form[ngxVestForm]',
1800
2201
  exportAs: 'scVestForm, ngxVestForm',
1801
2202
  host: {
1802
- '(focusout)': 'onFormFocusOut()',
2203
+ '(focusout)': 'onFormFocusOut($event)',
1803
2204
  },
1804
2205
  }]
1805
- }], 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"] }] } });
2206
+ }], 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"] }], fieldBlur: [{ type: i0.Output, args: ["fieldBlur"] }] } });
2207
+ /**
2208
+ * Reads the user-entered value from a blurred form element. Returns
2209
+ * `undefined` for radio inputs (caller must fall back to the bound
2210
+ * `control.value`, since the focused radio is not necessarily the
2211
+ * group's selected option) and for elements we don't handle.
2212
+ */
2213
+ function readElementValueForBlur(element) {
2214
+ if (element instanceof HTMLInputElement) {
2215
+ if (element.type === 'radio')
2216
+ return undefined;
2217
+ if (element.type === 'checkbox')
2218
+ return element.checked;
2219
+ if (element.type === 'number') {
2220
+ return element.value === '' ? null : element.valueAsNumber;
2221
+ }
2222
+ return element.value;
2223
+ }
2224
+ if (element instanceof HTMLTextAreaElement ||
2225
+ element instanceof HTMLSelectElement) {
2226
+ return element.value;
2227
+ }
2228
+ return undefined;
2229
+ }
2230
+ /**
2231
+ * Walks DOM ancestors between `start` (exclusive) and `formEl` (exclusive),
2232
+ * collecting any preserved `ngModelGroup` attribute values into a path
2233
+ * suitable for `FormGroup.get()`. Only the static attribute form is
2234
+ * preserved on the DOM; dynamically-bound `[ngModelGroup]` is handled by
2235
+ * `resolveControlPathByDomAncestors`.
2236
+ */
2237
+ function collectNgModelGroupAttributes(start, formEl) {
2238
+ const groups = [];
2239
+ let current = start.parentElement;
2240
+ while (current && current !== formEl && formEl.contains(current)) {
2241
+ const groupName = current.getAttribute('ngModelGroup')?.trim();
2242
+ if (groupName) {
2243
+ groups.unshift(groupName);
2244
+ }
2245
+ current = current.parentElement;
2246
+ }
2247
+ return groups;
2248
+ }
2249
+ /**
2250
+ * Resolves the dotted control path for a blurred element when the static
2251
+ * `ngModelGroup` attribute walk failed (typically because the host used
2252
+ * `[ngModelGroup]="expr"`, which Angular does not always preserve as a
2253
+ * DOM attribute). Walks the control tree top-down and at each FormGroup
2254
+ * boundary tries to descend into a child whose subtree contains an element
2255
+ * with the matching `name` — disambiguating repeated leaf names by DOM
2256
+ * containment instead of giving up.
2257
+ */
2258
+ function resolveControlPathByDomAncestors(root, fieldElement, formEl, leafName) {
2259
+ const descend = (frame) => {
2260
+ if (frame.control instanceof FormGroup) {
2261
+ for (const [key, child] of Object.entries(frame.control.controls)) {
2262
+ if (key === leafName && !(child instanceof FormGroup)) {
2263
+ return { control: child, path: [...frame.path, key] };
2264
+ }
2265
+ }
2266
+ const candidates = [];
2267
+ for (const [key, child] of Object.entries(frame.control.controls)) {
2268
+ if (child instanceof FormGroup || child instanceof FormArray) {
2269
+ if (subtreeContainsElement(child, fieldElement, formEl, key)) {
2270
+ candidates.push({ control: child, path: [...frame.path, key] });
2271
+ }
2272
+ }
2273
+ }
2274
+ if (candidates.length === 1 && candidates[0])
2275
+ return descend(candidates[0]);
2276
+ return null;
2277
+ }
2278
+ if (frame.control instanceof FormArray) {
2279
+ const candidates = [];
2280
+ frame.control.controls.forEach((child, index) => {
2281
+ if (child instanceof FormGroup || child instanceof FormArray) {
2282
+ if (subtreeContainsElement(child, fieldElement, formEl, index)) {
2283
+ candidates.push({ control: child, path: [...frame.path, index] });
2284
+ }
2285
+ }
2286
+ });
2287
+ if (candidates.length === 1 && candidates[0])
2288
+ return descend(candidates[0]);
2289
+ }
2290
+ return null;
2291
+ };
2292
+ const result = descend({ control: root, path: [] });
2293
+ if (!result)
2294
+ return null;
2295
+ return { path: stringifyFieldPath(result.path), control: result.control };
2296
+ }
2297
+ /**
2298
+ * Best-effort check: does the DOM subtree rooted at any element annotated
2299
+ * with `ngModelGroup="<key>"` contain `fieldElement`? Used by
2300
+ * `resolveControlPathByDomAncestors` to disambiguate repeated leaf names.
2301
+ * For dynamic `[ngModelGroup]` with no preserved attribute we cannot
2302
+ * disambiguate from DOM alone — those cases return `false`, matching the
2303
+ * documented limitation.
2304
+ */
2305
+ function subtreeContainsElement(_child, fieldElement, formEl, key) {
2306
+ if (typeof key !== 'string')
2307
+ return false;
2308
+ const selector = `[ngModelGroup="${CSS.escape(key)}"]`;
2309
+ const candidates = formEl.querySelectorAll(selector);
2310
+ for (const candidate of candidates) {
2311
+ if (candidate.contains(fieldElement))
2312
+ return true;
2313
+ }
2314
+ return false;
2315
+ }
2316
+ /**
2317
+ * Resolves the control + dotted path for a blurred element by consulting the
2318
+ * `NgModel` directives Angular registered with this `NgForm`. Each registered
2319
+ * directive carries a live `path` (the full ControlContainer chain) and a
2320
+ * value accessor whose element ref is the input the directive is hosted on.
2321
+ *
2322
+ * This handles all grouping shapes uniformly — static `ngModelGroup="key"`,
2323
+ * dynamic `[ngModelGroup]="expr"`, and repeated leaf names across siblings —
2324
+ * because the path comes from the live form tree rather than DOM heuristics.
2325
+ * Returns `null` when the directive can't be matched (e.g. a custom CVA that
2326
+ * doesn't store an element ref), so the caller can fall back to DOM probes.
2327
+ */
2328
+ function resolveControlPathByNgModelDirective(ngForm, fieldElement) {
2329
+ const directives = readNgFormDirectives(ngForm);
2330
+ if (!directives)
2331
+ return null;
2332
+ for (const directive of directives) {
2333
+ const accessorEl = readValueAccessorElement(directive.valueAccessor);
2334
+ if (accessorEl !== fieldElement)
2335
+ continue;
2336
+ const control = directive.control ?? ngForm.form.get(directive.path);
2337
+ if (!control)
2338
+ return null;
2339
+ return { path: directive.path.join('.'), control };
2340
+ }
2341
+ return null;
2342
+ }
2343
+ /**
2344
+ * Reads the registered `NgModel` directives from `NgForm`. Angular forms keeps
2345
+ * them in a private `_directives: Set<NgModel>`. The field name is stable
2346
+ * across all Angular versions that ship `ngModel`, but is not part of the
2347
+ * public type — callers must tolerate `null`.
2348
+ */
2349
+ function readNgFormDirectives(ngForm) {
2350
+ const set = ngForm._directives;
2351
+ return set ?? null;
2352
+ }
2353
+ /**
2354
+ * Reads the host element of a `ControlValueAccessor`. The standard accessors
2355
+ * shipped by Angular forms (default, number, select, radio, checkbox, range)
2356
+ * all store an `ElementRef` injected at construction as `_elementRef`. This
2357
+ * is private but stable; custom accessors that don't follow the convention
2358
+ * will simply miss the fast path.
2359
+ */
2360
+ function readValueAccessorElement(accessor) {
2361
+ if (!accessor)
2362
+ return null;
2363
+ const elementRef = accessor
2364
+ ._elementRef;
2365
+ const native = elementRef?.nativeElement;
2366
+ return native instanceof HTMLElement ? native : null;
2367
+ }
1806
2368
 
1807
2369
  const INITIAL_FORM_CONTROL_STATE = {
1808
2370
  status: 'INVALID',
@@ -1843,9 +2405,22 @@ class FormControlStateDirective {
1843
2405
  * Internal signal for control state (updated reactively)
1844
2406
  */
1845
2407
  #controlStateSignal;
2408
+ /**
2409
+ * Tick bumped once via `afterNextRender` if `control.control` is undefined
2410
+ * at the first effect run (NgModel registers asynchronously). Reading this
2411
+ * signal inside the effect causes a re-evaluation after the next render so
2412
+ * we pick up the late-attached `FormControl`. Bookkeeping below ensures we
2413
+ * only schedule a single retry — no permanent polling.
2414
+ */
2415
+ #controlAttachTick;
2416
+ // Latch is scoped to the *current* `#activeControl` instance. When the
2417
+ // active control changes (e.g. host swaps an `@if` block, NgModel
2418
+ // recreates), the latch resets so a fresh late-attach gets one retry.
2419
+ #controlAttachRetryScheduled;
2420
+ #lastSeenActiveControl;
1846
2421
  constructor() {
1847
- this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
1848
- this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : []));
2422
+ this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : /* istanbul ignore next */ []));
2423
+ this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : /* istanbul ignore next */ []));
1849
2424
  this.#hostNgModel = inject(NgModel, { self: true, optional: true });
1850
2425
  this.#hostNgModelGroup = inject(NgModelGroup, {
1851
2426
  self: true,
@@ -1864,7 +2439,7 @@ class FormControlStateDirective {
1864
2439
  this.#hostNgModelGroup ||
1865
2440
  this.contentNgModel() ||
1866
2441
  this.contentNgModelGroup() ||
1867
- null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
2442
+ null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : /* istanbul ignore next */ []));
1868
2443
  /**
1869
2444
  * Consolidated internal signal for interaction state tracking.
1870
2445
  * Combines touched, dirty, and hasBeenValidated into a single signal
@@ -1874,7 +2449,7 @@ class FormControlStateDirective {
1874
2449
  isTouched: false,
1875
2450
  isDirty: false,
1876
2451
  hasBeenValidated: false,
1877
- }, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
2452
+ }, ...(ngDevMode ? [{ debugName: "#interactionState" }] : /* istanbul ignore next */ []));
1878
2453
  /**
1879
2454
  * Track the previous status to detect actual status changes (not just status emissions).
1880
2455
  * This helps distinguish between initial control creation and actual re-validation.
@@ -1883,11 +2458,24 @@ class FormControlStateDirective {
1883
2458
  /**
1884
2459
  * Internal signal for control state (updated reactively)
1885
2460
  */
1886
- this.#controlStateSignal = signal(INITIAL_FORM_CONTROL_STATE, ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : []));
2461
+ this.#controlStateSignal = signal(INITIAL_FORM_CONTROL_STATE, ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : /* istanbul ignore next */ []));
2462
+ /**
2463
+ * Tick bumped once via `afterNextRender` if `control.control` is undefined
2464
+ * at the first effect run (NgModel registers asynchronously). Reading this
2465
+ * signal inside the effect causes a re-evaluation after the next render so
2466
+ * we pick up the late-attached `FormControl`. Bookkeeping below ensures we
2467
+ * only schedule a single retry — no permanent polling.
2468
+ */
2469
+ this.#controlAttachTick = signal(0, ...(ngDevMode ? [{ debugName: "#controlAttachTick" }] : /* istanbul ignore next */ []));
2470
+ // Latch is scoped to the *current* `#activeControl` instance. When the
2471
+ // active control changes (e.g. host swaps an `@if` block, NgModel
2472
+ // recreates), the latch resets so a fresh late-attach gets one retry.
2473
+ this.#controlAttachRetryScheduled = false;
2474
+ this.#lastSeenActiveControl = null;
1887
2475
  /**
1888
2476
  * Main control state computed signal (merges robust touched/dirty)
1889
2477
  */
1890
- this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
2478
+ this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : /* istanbul ignore next */ []));
1891
2479
  /**
1892
2480
  * Extracts error messages from Angular/Vest errors (recursively flattens)
1893
2481
  */
@@ -1907,20 +2495,20 @@ class FormControlStateDirective {
1907
2495
  const errorsWithoutWarnings = { ...errors };
1908
2496
  delete errorsWithoutWarnings['warnings'];
1909
2497
  return this.#flattenAngularErrors(errorsWithoutWarnings);
1910
- }, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
2498
+ }, ...(ngDevMode ? [{ debugName: "errorMessages" }] : /* istanbul ignore next */ []));
1911
2499
  /**
1912
2500
  * ADVANCED: updateOn strategy (change/blur/submit) if available
1913
2501
  */
1914
2502
  this.updateOn = computed(() => {
1915
2503
  const ngModel = this.contentNgModel() || this.#hostNgModel;
1916
2504
  return ngModel?.options?.updateOn ?? 'change';
1917
- }, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
2505
+ }, ...(ngDevMode ? [{ debugName: "updateOn" }] : /* istanbul ignore next */ []));
1918
2506
  /**
1919
2507
  * ADVANCED: Composite/derived signals for advanced error display logic
1920
2508
  */
1921
- this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
1922
- this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
1923
- this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
2509
+ this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : /* istanbul ignore next */ []));
2510
+ this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : /* istanbul ignore next */ []));
2511
+ this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : /* istanbul ignore next */ []));
1924
2512
  /**
1925
2513
  * Extracts warning messages from Vest validation results.
1926
2514
  * Checks two sources:
@@ -1955,36 +2543,59 @@ class FormControlStateDirective {
1955
2543
  }
1956
2544
  }
1957
2545
  return [];
1958
- }, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
2546
+ }, ...(ngDevMode ? [{ debugName: "warningMessages" }] : /* istanbul ignore next */ []));
1959
2547
  /**
1960
2548
  * Whether async validation is in progress
1961
2549
  */
1962
- this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
2550
+ this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : /* istanbul ignore next */ []));
1963
2551
  /**
1964
2552
  * Convenience signals for common state checks
1965
2553
  */
1966
- this.isValid = computed(() => this.controlState().isValid, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
1967
- this.isInvalid = computed(() => this.controlState().isInvalid, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
1968
- this.isPending = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
1969
- this.isTouched = computed(() => this.controlState().isTouched, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
1970
- this.isDirty = computed(() => this.controlState().isDirty, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
1971
- this.isPristine = computed(() => this.controlState().isPristine, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
1972
- this.isDisabled = computed(() => this.controlState().isDisabled, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
1973
- this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : []));
2554
+ this.isValid = computed(() => this.controlState().isValid, ...(ngDevMode ? [{ debugName: "isValid" }] : /* istanbul ignore next */ []));
2555
+ this.isInvalid = computed(() => this.controlState().isInvalid, ...(ngDevMode ? [{ debugName: "isInvalid" }] : /* istanbul ignore next */ []));
2556
+ this.isPending = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "isPending" }] : /* istanbul ignore next */ []));
2557
+ this.isTouched = computed(() => this.controlState().isTouched, ...(ngDevMode ? [{ debugName: "isTouched" }] : /* istanbul ignore next */ []));
2558
+ this.isDirty = computed(() => this.controlState().isDirty, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
2559
+ this.isPristine = computed(() => this.controlState().isPristine, ...(ngDevMode ? [{ debugName: "isPristine" }] : /* istanbul ignore next */ []));
2560
+ this.isDisabled = computed(() => this.controlState().isDisabled, ...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
2561
+ this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : /* istanbul ignore next */ []));
1974
2562
  /**
1975
2563
  * Whether this control has been validated at least once.
1976
2564
  * True after the first validation completes, even if the user hasn't touched the field.
1977
- * This enables showing errors for validationConfig-triggered validations.
2565
+ * This is primarily used for warning display and other derived state that should react
2566
+ * to validationConfig-triggered validation even before the user touches the field.
1978
2567
  */
1979
- this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : []));
2568
+ this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : /* istanbul ignore next */ []));
1980
2569
  // Update control state reactively with proper cleanup
1981
2570
  effect((onCleanup) => {
1982
2571
  const control = this.#activeControl();
1983
2572
  const interaction = this.#interactionState();
2573
+ // Track the retry tick so a late-attached `control.control` re-runs this
2574
+ // effect after the next render.
2575
+ this.#controlAttachTick();
2576
+ // Re-arm the late-attach latch whenever the active control identity
2577
+ // changes — including transitions to/from null — so a newly mounted
2578
+ // directive whose `FormControl` registers asynchronously gets its own
2579
+ // single retry.
2580
+ if (control !== this.#lastSeenActiveControl) {
2581
+ this.#lastSeenActiveControl = control;
2582
+ this.#controlAttachRetryScheduled = false;
2583
+ }
1984
2584
  if (!control) {
1985
2585
  this.#controlStateSignal.set(INITIAL_FORM_CONTROL_STATE);
1986
2586
  return;
1987
2587
  }
2588
+ // NgModel attaches its `FormControl` during its own ngOnInit, which can
2589
+ // run after this effect's first execution in some host orderings. If
2590
+ // `control.control` isn't there yet, schedule a single `afterNextRender`
2591
+ // retry so we re-evaluate once Angular finishes wiring directives.
2592
+ if (!control.control && !this.#controlAttachRetryScheduled) {
2593
+ this.#controlAttachRetryScheduled = true;
2594
+ afterNextRender(() => {
2595
+ this.#controlAttachTick.update((v) => v + 1);
2596
+ }, { injector: this.#injector });
2597
+ return;
2598
+ }
1988
2599
  // Listen to control changes
1989
2600
  const sub = control.control?.statusChanges?.subscribe(() => {
1990
2601
  const { status, valid, invalid, pending, disabled, pristine, errors, touched, } = control;
@@ -2082,6 +2693,23 @@ class FormControlStateDirective {
2082
2693
  ? true
2083
2694
  : state.hasBeenValidated,
2084
2695
  }));
2696
+ // Keep the derived control-state signal in sync even when blur/dirty
2697
+ // changes do not produce a statusChanges emission.
2698
+ //
2699
+ // This happens when a control is already INVALID due to dependent-field
2700
+ // validation and the user then blurs it. Error display modes that depend
2701
+ // on `isTouched()` must still update immediately in that case.
2702
+ this.#controlStateSignal.set({
2703
+ status: control.status,
2704
+ isValid: control.valid ?? false,
2705
+ isInvalid: control.invalid ?? false,
2706
+ isPending: control.pending ?? false,
2707
+ isDisabled: control.disabled ?? false,
2708
+ isTouched: newTouched,
2709
+ isDirty: newDirty,
2710
+ isPristine: control.pristine ?? true,
2711
+ errors: control.errors,
2712
+ });
2085
2713
  }
2086
2714
  // Sync pending state only when it transitions from true to false
2087
2715
  // This fixes "Validating..." being stuck when statusChanges misses the transition
@@ -2132,10 +2760,10 @@ class FormControlStateDirective {
2132
2760
  }
2133
2761
  return result;
2134
2762
  }
2135
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2136
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.3", 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 }); }
2763
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2764
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.2.11", 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 }); }
2137
2765
  }
2138
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormControlStateDirective, decorators: [{
2766
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormControlStateDirective, decorators: [{
2139
2767
  type: Directive,
2140
2768
  args: [{
2141
2769
  selector: '[formControlState], [ngxControlState]',
@@ -2178,23 +2806,27 @@ class FormErrorDisplayDirective {
2178
2806
  #formControlState;
2179
2807
  // Optionally inject NgForm for form submission tracking
2180
2808
  #ngForm;
2809
+ #formSubmittedState;
2181
2810
  constructor() {
2182
2811
  this.#formControlState = inject(FormControlStateDirective);
2183
2812
  // Optionally inject NgForm for form submission tracking
2184
2813
  this.#ngForm = inject(NgForm, { optional: true });
2814
+ this.#formSubmittedState = this.#ngForm
2815
+ ? getFormSubmittedSignal(this.#ngForm)
2816
+ : signal(false);
2185
2817
  /**
2186
2818
  * Input signal for error display mode.
2187
2819
  * Works seamlessly with hostDirectives in Angular 19+.
2188
2820
  */
2189
2821
  this.errorDisplayMode = input(inject(NGX_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
2190
2822
  inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
2191
- SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
2823
+ SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : /* istanbul ignore next */ []));
2192
2824
  /**
2193
2825
  * Input signal for warning display mode.
2194
2826
  * Controls whether warnings are shown only after touch or also after validation.
2195
2827
  */
2196
2828
  this.warningDisplayMode = input(inject(NGX_WARNING_DISPLAY_MODE_TOKEN, { optional: true }) ??
2197
- SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : []));
2829
+ SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : /* istanbul ignore next */ []));
2198
2830
  // Expose state signals from FormControlStateDirective
2199
2831
  this.controlState = this.#formControlState.controlState;
2200
2832
  this.errorMessages = this.#formControlState.errorMessages;
@@ -2219,27 +2851,25 @@ class FormErrorDisplayDirective {
2219
2851
  * This keeps programmatic `NgForm.onSubmit()` reactive in zoneless mode and
2220
2852
  * avoids depending on `NgForm.submitted`, whose getter intentionally reads an
2221
2853
  * internal signal with `untracked()`.
2854
+ *
2855
+ * Note: when this directive is used outside an `NgForm` (no parent form), no
2856
+ * subscription is wired up and this signal stays `false` for the lifetime of
2857
+ * the directive. Consumers relying on submitted state must host the field
2858
+ * inside an `NgForm` (or `ngxVestForm`).
2222
2859
  */
2223
- this.formSubmitted = this.#ngForm
2224
- ? (() => {
2225
- const ngForm = this.#ngForm;
2226
- return toSignal(ngForm.form.events.pipe(filter((event) => event.source === ngForm.form &&
2227
- (event instanceof FormSubmittedEvent ||
2228
- event instanceof FormResetEvent)), map((event) => event instanceof FormSubmittedEvent), startWith(ngForm.submitted)), { initialValue: ngForm.submitted });
2229
- })()
2230
- : signal(false);
2860
+ this.formSubmitted = this.#formSubmittedState;
2231
2861
  /**
2232
2862
  * Determines if errors should be shown based on the specified display mode
2233
- * and the control's state (touched/submitted/validated).
2863
+ * and the control's state (touched/submitted/dirty).
2234
2864
  *
2235
2865
  * Note: We check both hasErrors (extracted error messages) AND isInvalid (Angular's validation state)
2236
2866
  * because in some cases (like conditional validations via validationConfig), the control is marked
2237
2867
  * as invalid by Angular before error messages are extracted from Vest. This ensures aria-invalid
2238
2868
  * is set correctly even during the validation propagation delay.
2239
2869
  *
2240
- * For validationConfig-triggered validations: A field can be validated without being touched
2241
- * (e.g., confirmPassword validated when password changes). We check hasBeenValidated to show
2242
- * errors in these scenarios, providing better UX and proper ARIA attributes.
2870
+ * For validationConfig-triggered validations, a field may become invalid before it has been
2871
+ * touched. Error visibility still respects the field's own `errorDisplayMode`, so untouched
2872
+ * dependent fields can remain visually quiet until blur or submit.
2243
2873
  */
2244
2874
  this.shouldShowErrors = computed(() => {
2245
2875
  const mode = this.errorDisplayMode();
@@ -2275,7 +2905,7 @@ class FormErrorDisplayDirective {
2275
2905
  // Show after blur (touch) OR submit (default behavior)
2276
2906
  return !!((isTouched || formSubmitted) && hasErrorState);
2277
2907
  }
2278
- }, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
2908
+ }, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : /* istanbul ignore next */ []));
2279
2909
  /**
2280
2910
  * Errors to display (filtered for pending state)
2281
2911
  */
@@ -2283,7 +2913,7 @@ class FormErrorDisplayDirective {
2283
2913
  if (this.hasPendingValidation())
2284
2914
  return [];
2285
2915
  return this.errorMessages();
2286
- }, ...(ngDevMode ? [{ debugName: "errors" }] : []));
2916
+ }, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
2287
2917
  /**
2288
2918
  * Warnings to display (filtered for pending state)
2289
2919
  */
@@ -2291,7 +2921,7 @@ class FormErrorDisplayDirective {
2291
2921
  if (this.hasPendingValidation())
2292
2922
  return [];
2293
2923
  return this.warningMessages();
2294
- }, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
2924
+ }, ...(ngDevMode ? [{ debugName: "warnings" }] : /* istanbul ignore next */ []));
2295
2925
  /**
2296
2926
  * Whether the control is currently being validated (pending)
2297
2927
  * Excludes pristine+untouched controls to prevent "Validating..." on initial load
@@ -2304,7 +2934,7 @@ class FormErrorDisplayDirective {
2304
2934
  return false;
2305
2935
  }
2306
2936
  return this.hasPendingValidation();
2307
- }, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
2937
+ }, ...(ngDevMode ? [{ debugName: "isPending" }] : /* istanbul ignore next */ []));
2308
2938
  /**
2309
2939
  * Determines if warnings should be shown based on the specified display mode
2310
2940
  * and the control's state (touched/validated/dirty).
@@ -2345,7 +2975,17 @@ class FormErrorDisplayDirective {
2345
2975
  // Show after validation runs or after touch/submit (default behavior)
2346
2976
  return hasBeenValidated || isTouched || formSubmitted;
2347
2977
  }
2348
- }, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : []));
2978
+ }, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : /* istanbul ignore next */ []));
2979
+ const ngForm = this.#ngForm;
2980
+ if (ngForm) {
2981
+ ngForm.form.events
2982
+ .pipe(filter((event) => event.source === ngForm.form &&
2983
+ (event instanceof FormSubmittedEvent ||
2984
+ event instanceof FormResetEvent)), map((event) => event instanceof FormSubmittedEvent), startWith(ngForm.submitted), takeUntilDestroyed())
2985
+ .subscribe((submitted) => {
2986
+ this.#formSubmittedState.set(submitted);
2987
+ });
2988
+ }
2349
2989
  // Warn about problematic combinations of updateOn and errorDisplayMode
2350
2990
  effect(() => {
2351
2991
  const mode = this.errorDisplayMode();
@@ -2355,10 +2995,10 @@ class FormErrorDisplayDirective {
2355
2995
  }
2356
2996
  });
2357
2997
  }
2358
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2359
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.3", 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 }); }
2998
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2999
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", 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 }); }
2360
3000
  }
2361
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
3001
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
2362
3002
  type: Directive,
2363
3003
  args: [{
2364
3004
  selector: '[formErrorDisplay], [ngxErrorDisplay]',
@@ -2443,9 +3083,17 @@ function resolveAssociationTargets(controls, mode) {
2443
3083
  * @returns Object containing the debounced showPendingMessage signal and cleanup function
2444
3084
  */
2445
3085
  function createDebouncedPendingState(isPending, options = {}) {
2446
- const { showAfter = 200, minimumDisplay = 500 } = options;
3086
+ // Reading the options inside the effect makes the timings reactive: when
3087
+ // a consumer passes an `input()` accessor (a Signal), changes propagate at
3088
+ // runtime rather than getting captured once at construction.
3089
+ const optionsSignal = isSignal(options)
3090
+ ? options
3091
+ : null;
3092
+ const staticOptions = optionsSignal
3093
+ ? {}
3094
+ : options;
2447
3095
  // Create writable signal for debounced state
2448
- const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : []));
3096
+ const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : /* istanbul ignore next */ []));
2449
3097
  // Track timeouts
2450
3098
  let pendingTimeout = null;
2451
3099
  let minimumDisplayTimeout = null;
@@ -2463,6 +3111,9 @@ function createDebouncedPendingState(isPending, options = {}) {
2463
3111
  // Effect to manage debounced pending message display
2464
3112
  effect((onCleanup) => {
2465
3113
  const pending = isPending();
3114
+ const current = optionsSignal ? optionsSignal() : staticOptions;
3115
+ const showAfter = current.showAfter ?? 200;
3116
+ const minimumDisplay = current.minimumDisplay ?? 500;
2466
3117
  if (pending) {
2467
3118
  // Clear any existing minimum display timeout
2468
3119
  if (minimumDisplayTimeout) {
@@ -2647,16 +3298,16 @@ class ControlWrapperComponent {
2647
3298
  * across multiple child controls.
2648
3299
  * - This does not affect whether messages render; it only affects ARIA wiring.
2649
3300
  */
2650
- this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
3301
+ this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : /* istanbul ignore next */ []));
2651
3302
  // Generate unique IDs for ARIA associations
2652
3303
  this.uniqueId = `ngx-control-wrapper-${nextUniqueId$2++}`;
2653
3304
  this.errorId = `${this.uniqueId}-error`;
2654
3305
  this.warningId = `${this.uniqueId}-warning`;
2655
3306
  this.pendingId = `${this.uniqueId}-pending`;
2656
3307
  // Track form controls found in the wrapper
2657
- this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
3308
+ this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : /* istanbul ignore next */ []));
2658
3309
  // Signals when content is initialized so effects can safely touch the DOM.
2659
- this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
3310
+ this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : /* istanbul ignore next */ []));
2660
3311
  // MutationObserver to detect dynamically added/removed controls
2661
3312
  this.mutationObserver = null;
2662
3313
  /**
@@ -2688,7 +3339,7 @@ class ControlWrapperComponent {
2688
3339
  ids.push(this.pendingId);
2689
3340
  }
2690
3341
  return ids.length > 0 ? ids.join(' ') : null;
2691
- }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
3342
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : /* istanbul ignore next */ []));
2692
3343
  /**
2693
3344
  * IDs managed by this wrapper when composing aria-describedby.
2694
3345
  *
@@ -2781,10 +3432,10 @@ class ControlWrapperComponent {
2781
3432
  const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
2782
3433
  this.formControls.set(Array.from(controls));
2783
3434
  }
2784
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2785
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", 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 }); }
3435
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3436
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.11", 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 }); }
2786
3437
  }
2787
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ControlWrapperComponent, decorators: [{
3438
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ControlWrapperComponent, decorators: [{
2788
3439
  type: Component,
2789
3440
  args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2790
3441
  class: 'ngx-control-wrapper sc-control-wrapper',
@@ -2820,12 +3471,12 @@ class FormGroupWrapperComponent {
2820
3471
  this.pendingDebounce = input({
2821
3472
  showAfter: 500,
2822
3473
  minimumDisplay: 500,
2823
- }, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : []));
3474
+ }, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : /* istanbul ignore next */ []));
2824
3475
  this.uniqueId = `ngx-form-group-wrapper-${nextUniqueId$1++}`;
2825
3476
  this.errorId = `${this.uniqueId}-error`;
2826
3477
  this.warningId = `${this.uniqueId}-warning`;
2827
3478
  this.pendingId = `${this.uniqueId}-pending`;
2828
- this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce());
3479
+ this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce);
2829
3480
  this.showPendingMessage = this.pendingState.showPendingMessage;
2830
3481
  /**
2831
3482
  * Helpful if consumers want to wire aria-describedby manually (e.g. fieldset/legend pattern).
@@ -2842,12 +3493,12 @@ class FormGroupWrapperComponent {
2842
3493
  ids.push(this.pendingId);
2843
3494
  }
2844
3495
  return ids.length > 0 ? ids.join(' ') : null;
2845
- }, ...(ngDevMode ? [{ debugName: "describedByIds" }] : []));
3496
+ }, ...(ngDevMode ? [{ debugName: "describedByIds" }] : /* istanbul ignore next */ []));
2846
3497
  }
2847
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2848
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper]", 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: ["ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode", "warningDisplayMode", "warningDisplayMode"] }], 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.shouldShowWarnings()) {\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 }); }
3498
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3499
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.11", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper]", 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: ["ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode", "warningDisplayMode", "warningDisplayMode"] }], 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.shouldShowWarnings()) {\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 }); }
2849
3500
  }
2850
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
3501
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
2851
3502
  type: Component,
2852
3503
  args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper]', exportAs: 'ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2853
3504
  class: 'ngx-form-group-wrapper sc-form-group-wrapper',
@@ -2884,7 +3535,7 @@ class FormErrorControlDirective {
2884
3535
  * - `single-control`: apply ARIA attributes only when exactly one control is found.
2885
3536
  * - `none`: do not mutate descendant controls.
2886
3537
  */
2887
- this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
3538
+ this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : /* istanbul ignore next */ []));
2888
3539
  /**
2889
3540
  * Unique ID prefix for this instance.
2890
3541
  * Use these IDs to render message regions and to support aria-describedby.
@@ -2893,8 +3544,8 @@ class FormErrorControlDirective {
2893
3544
  this.errorId = `${this.uniqueId}-error`;
2894
3545
  this.warningId = `${this.uniqueId}-warning`;
2895
3546
  this.pendingId = `${this.uniqueId}-pending`;
2896
- this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
2897
- this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
3547
+ this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : /* istanbul ignore next */ []));
3548
+ this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : /* istanbul ignore next */ []));
2898
3549
  this.mutationObserver = null;
2899
3550
  this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
2900
3551
  this.showPendingMessage = this.pendingState.showPendingMessage;
@@ -2913,7 +3564,7 @@ class FormErrorControlDirective {
2913
3564
  ids.push(this.pendingId);
2914
3565
  }
2915
3566
  return ids.length > 0 ? ids.join(' ') : null;
2916
- }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
3567
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : /* istanbul ignore next */ []));
2917
3568
  this.ownedDescribedByIds = [
2918
3569
  this.errorId,
2919
3570
  this.warningId,
@@ -2987,10 +3638,10 @@ class FormErrorControlDirective {
2987
3638
  const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
2988
3639
  this.formControls.set(Array.from(controls));
2989
3640
  }
2990
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2991
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.3", 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", "warningDisplayMode", "warningDisplayMode"] }], ngImport: i0 }); }
3641
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3642
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", 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", "warningDisplayMode", "warningDisplayMode"] }], ngImport: i0 }); }
2992
3643
  }
2993
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorControlDirective, decorators: [{
3644
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorControlDirective, decorators: [{
2994
3645
  type: Directive,
2995
3646
  args: [{
2996
3647
  selector: '[formErrorControl], [ngxErrorControl]',
@@ -3054,7 +3705,7 @@ class FormModelGroupDirective {
3054
3705
  *
3055
3706
  * Defaults to no debounce (`{ debounceTime: 0 }`).
3056
3707
  */
3057
- this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
3708
+ this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
3058
3709
  this.formDirective = inject(FormDirective, { optional: true });
3059
3710
  }
3060
3711
  /**
@@ -3070,8 +3721,8 @@ class FormModelGroupDirective {
3070
3721
  return getFormGroupField(context.ngForm.control, currentControl);
3071
3722
  }, this.validationOptions(), 'FormModelGroupDirective');
3072
3723
  }
3073
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3074
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.3", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup],[ngxModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
3724
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3725
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup],[ngxModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
3075
3726
  {
3076
3727
  provide: NG_ASYNC_VALIDATORS,
3077
3728
  useExisting: FormModelGroupDirective,
@@ -3079,7 +3730,7 @@ class FormModelGroupDirective {
3079
3730
  },
3080
3731
  ], ngImport: i0 }); }
3081
3732
  }
3082
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelGroupDirective, decorators: [{
3733
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelGroupDirective, decorators: [{
3083
3734
  type: Directive,
3084
3735
  args: [{
3085
3736
  selector: '[ngModelGroup],[ngxModelGroup]',
@@ -3104,7 +3755,7 @@ class FormModelDirective {
3104
3755
  *
3105
3756
  * Defaults to no debounce (`{ debounceTime: 0 }`).
3106
3757
  */
3107
- this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
3758
+ this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
3108
3759
  /**
3109
3760
  * Reference to the form that needs to be validated
3110
3761
  * Injected optionally so that using ngModel outside of an ngxVestForm
@@ -3127,8 +3778,8 @@ class FormModelDirective {
3127
3778
  return getFormControlField(context.ngForm.control, currentControl);
3128
3779
  }, this.validationOptions(), 'FormModelDirective');
3129
3780
  }
3130
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3131
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.3", type: FormModelDirective, isStandalone: true, selector: "[ngModel],[ngxModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
3781
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3782
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: FormModelDirective, isStandalone: true, selector: "[ngModel],[ngxModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
3132
3783
  {
3133
3784
  provide: NG_ASYNC_VALIDATORS,
3134
3785
  useExisting: FormModelDirective,
@@ -3136,7 +3787,7 @@ class FormModelDirective {
3136
3787
  },
3137
3788
  ], ngImport: i0 }); }
3138
3789
  }
3139
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelDirective, decorators: [{
3790
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelDirective, decorators: [{
3140
3791
  type: Directive,
3141
3792
  args: [{
3142
3793
  selector: '[ngModel],[ngxModel]',
@@ -3225,26 +3876,30 @@ class ValidateRootFormDirective {
3225
3876
  constructor() {
3226
3877
  this.injector = inject(Injector);
3227
3878
  this.destroyRef = inject(DestroyRef);
3228
- this.lastControl = signal(null, ...(ngDevMode ? [{ debugName: "lastControl" }] : []));
3229
- this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
3230
- this.hasSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "hasSubmitted" }] : []));
3231
- this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
3232
- this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
3879
+ this.lastControl = signal(null, ...(ngDevMode ? [{ debugName: "lastControl" }] : /* istanbul ignore next */ []));
3880
+ this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
3881
+ this.hasSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "hasSubmitted" }] : /* istanbul ignore next */ []));
3882
+ this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : /* istanbul ignore next */ []));
3883
+ this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : /* istanbul ignore next */ []));
3233
3884
  /**
3234
3885
  * Whether the root form should be validated or not
3235
3886
  * This will use the field rootForm
3236
3887
  * Accepts both validateRootForm and ngxValidateRootForm
3237
3888
  */
3238
- this.validateRootForm = input(false, { ...(ngDevMode ? { debugName: "validateRootForm" } : {}), transform: booleanAttribute });
3239
- this.ngxValidateRootForm = input(false, { ...(ngDevMode ? { debugName: "ngxValidateRootForm" } : {}), transform: booleanAttribute });
3889
+ this.validateRootForm = input(false, { ...(ngDevMode ? { debugName: "validateRootForm" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
3890
+ this.ngxValidateRootForm = input(false, { ...(ngDevMode ? { debugName: "ngxValidateRootForm" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
3240
3891
  /**
3241
3892
  * Validation mode:
3242
- * - 'submit' (default): Only validates after form submission
3243
- * - 'live': Validates on every value change
3244
- * Accepts both validateRootFormMode and ngxValidateRootFormMode
3893
+ * - `'submit'` (effective default): Only validates after form submission.
3894
+ * - `'live'`: Validates on every value change.
3895
+ *
3896
+ * Both inputs default to `undefined` so we can detect whether the consumer
3897
+ * set them explicitly. Precedence is `ngx ?? legacy ?? 'submit'`, which
3898
+ * matches the documented behavior — observable only when both attributes
3899
+ * are set explicitly on the same form.
3245
3900
  */
3246
- this.validateRootFormMode = input('submit', ...(ngDevMode ? [{ debugName: "validateRootFormMode" }] : []));
3247
- this.ngxValidateRootFormMode = input('submit', ...(ngDevMode ? [{ debugName: "ngxValidateRootFormMode" }] : []));
3901
+ this.validateRootFormMode = input(undefined, ...(ngDevMode ? [{ debugName: "validateRootFormMode" }] : /* istanbul ignore next */ []));
3902
+ this.ngxValidateRootFormMode = input(undefined, ...(ngDevMode ? [{ debugName: "ngxValidateRootFormMode" }] : /* istanbul ignore next */ []));
3248
3903
  // Convert signals to Observables in injection context
3249
3904
  this.hasSubmitted$ = toObservable(this.hasSubmitted);
3250
3905
  this.formValue$ = toObservable(this.formValue);
@@ -3269,7 +3924,9 @@ class ValidateRootFormDirective {
3269
3924
  if (ngForm?.control) {
3270
3925
  // Defer to the next microtask so Angular has a chance to finish
3271
3926
  // wiring up controls/groups (ngModel/ngModelGroup) on initial render.
3272
- queueMicrotask(() => ngForm.control.updateValueAndValidity());
3927
+ // The scheduleMicrotask primitive auto-cancels if the directive is
3928
+ // destroyed before the microtask fires.
3929
+ scheduleMicrotask(() => ngForm.control.updateValueAndValidity(), this.destroyRef);
3273
3930
  }
3274
3931
  });
3275
3932
  }
@@ -3291,7 +3948,7 @@ class ValidateRootFormDirective {
3291
3948
  // Ensure we run at least one validation pass after the form is ready.
3292
3949
  // This matters for 'live' mode root-form errors that should appear
3293
3950
  // without requiring a user interaction.
3294
- queueMicrotask(() => ngForm.control.updateValueAndValidity());
3951
+ scheduleMicrotask(() => ngForm.control.updateValueAndValidity(), this.destroyRef);
3295
3952
  // Subscribe to form submission to set hasSubmitted flag
3296
3953
  ngForm.ngSubmit
3297
3954
  .pipe(tap(() => {
@@ -3309,10 +3966,12 @@ class ValidateRootFormDirective {
3309
3966
  if (!isEnabled) {
3310
3967
  return of(null);
3311
3968
  }
3312
- // Get mode from either input (ngx prefix takes precedence if both set)
3313
- const mode = this.ngxValidateRootFormMode() !== 'submit'
3314
- ? this.ngxValidateRootFormMode()
3315
- : this.validateRootFormMode();
3969
+ // Mode precedence: ngx-prefixed input wins over legacy input; both default
3970
+ // to `undefined` so the precedence rule is implementable without losing
3971
+ // the legacy attribute when the new one is not set.
3972
+ const mode = this.ngxValidateRootFormMode() ??
3973
+ this.validateRootFormMode() ??
3974
+ 'submit';
3316
3975
  // In 'submit' mode, skip validation until form is submitted
3317
3976
  if (mode === 'submit' && !this.hasSubmitted()) {
3318
3977
  return of(null);
@@ -3373,8 +4032,8 @@ class ValidateRootFormDirective {
3373
4032
  }), take(1), takeUntilDestroyed(this.destroyRef));
3374
4033
  };
3375
4034
  }
3376
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3377
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.3", 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: [
4035
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
4036
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", 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: [
3378
4037
  {
3379
4038
  provide: NG_ASYNC_VALIDATORS,
3380
4039
  useExisting: ValidateRootFormDirective,
@@ -3382,7 +4041,7 @@ class ValidateRootFormDirective {
3382
4041
  },
3383
4042
  ], ngImport: i0 }); }
3384
4043
  }
3385
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
4044
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
3386
4045
  type: Directive,
3387
4046
  args: [{
3388
4047
  selector: 'form[validateRootForm], form[ngxValidateRootForm]',
@@ -3609,7 +4268,7 @@ class ValidationConfigBuilder {
3609
4268
  */
3610
4269
  whenChanged(trigger, revalidate) {
3611
4270
  const deps = Array.isArray(revalidate) ? revalidate : [revalidate];
3612
- const existing = this.config[trigger] || [];
4271
+ const existing = (this.config[trigger] || []);
3613
4272
  // Development mode warning for duplicate dependents
3614
4273
  if (typeof ngDevMode !== 'undefined' && ngDevMode) {
3615
4274
  const duplicates = deps.filter((d) => existing.includes(d));
@@ -4231,7 +4890,7 @@ function keepFieldsWhen(currentState, conditions) {
4231
4890
  const result = {};
4232
4891
  for (const fieldName of Object.keys(conditions)) {
4233
4892
  const shouldKeep = conditions[fieldName];
4234
- if (shouldKeep && fieldName in currentState) {
4893
+ if (shouldKeep && Object.hasOwn(currentState, fieldName)) {
4235
4894
  result[fieldName] = currentState[fieldName];
4236
4895
  }
4237
4896
  }
@@ -4246,5 +4905,5 @@ function keepFieldsWhen(currentState, conditions) {
4246
4905
  * Generated bundle index. Do not edit.
4247
4906
  */
4248
4907
 
4249
- export { ControlWrapperComponent, DEFAULT_FOCUS_SELECTOR, DEFAULT_INVALID_SELECTOR, 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, mergeAriaDescribedBy, mergeValuesAndRawValues, objectToArray, parseAriaIdTokens, parseFieldPath, resolveAssociationTargets, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
4908
+ export { ControlWrapperComponent, DEFAULT_FOCUS_SELECTOR, DEFAULT_INVALID_SELECTOR, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_EQUALITY_FN, 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, mergeAriaDescribedBy, mergeValuesAndRawValues, objectToArray, parseAriaIdTokens, parseFieldPath, resolveAssociationTargets, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
4250
4909
  //# sourceMappingURL=ngx-vest-forms.mjs.map