ngx-vest-forms 2.6.0 → 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, signal, inject, ElementRef, DestroyRef, ChangeDetectorRef, 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,26 +1066,41 @@ 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
  }
@@ -981,12 +1191,22 @@ const VALIDATION_IN_PROGRESS_TIMEOUT_MS = 500;
981
1191
  * @publicApi
982
1192
  */
983
1193
  class FormDirective {
984
- // Track last linked value to prevent unnecessary updates
985
- #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;
986
1204
  #lastSyncedFormValue;
987
1205
  #lastSyncedModelValue;
988
- // Internal signal tracking form value changes via statusChanges
989
- #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;
990
1210
  /**
991
1211
  * LinkedSignal that computes form values from Angular form state.
992
1212
  * This eliminates timing issues with the previous dual-effect pattern.
@@ -1013,36 +1233,43 @@ class FormDirective {
1013
1233
  this.destroyRef = inject(DestroyRef);
1014
1234
  this.cdr = inject(ChangeDetectorRef);
1015
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);
1016
1241
  /**
1017
1242
  * Public signal storing field warnings keyed by field path.
1018
1243
  * This allows warnings to be stored and displayed without affecting field validity.
1019
1244
  * Angular's control.errors !== null marks a field as invalid, so we store warnings
1020
1245
  * separately when they exist without errors.
1021
1246
  */
1022
- this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : []));
1023
- // Track last linked value to prevent unnecessary updates
1024
- 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;
1025
1253
  this.#lastSyncedFormValue = null;
1026
1254
  this.#lastSyncedModelValue = null;
1027
- // Internal signal tracking form value changes via statusChanges
1028
- 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 });
1029
1259
  /**
1030
1260
  * LinkedSignal that computes form values from Angular form state.
1031
1261
  * This eliminates timing issues with the previous dual-effect pattern.
1032
1262
  */
1033
1263
  this.#formValueSignal = linkedSignal(() => {
1034
- // Track form value changes
1035
- this.#value();
1036
- const raw = mergeValuesAndRawValues(this.ngForm.form);
1037
- if (Object.keys(this.ngForm.form.controls).length > 0) {
1038
- this.#lastLinkedValue = raw;
1039
- return raw;
1040
- }
1041
- else if (this.#lastLinkedValue !== null) {
1042
- 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;
1043
1270
  }
1044
- return null;
1045
- }, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : []));
1271
+ return mergeValuesAndRawValues(this.ngForm.form);
1272
+ }, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : /* istanbul ignore next */ []));
1046
1273
  /**
1047
1274
  * Track the Angular form status as a signal for advanced status flags
1048
1275
  */
@@ -1052,7 +1279,7 @@ class FormDirective {
1052
1279
  * This guarantees recomputation for every blur/tab interaction,
1053
1280
  * even when the form's aggregate touched flag is already true.
1054
1281
  */
1055
- this.#blurTick = signal(0, ...(ngDevMode ? [{ debugName: "#blurTick" }] : []));
1282
+ this.#blurTick = signal(0, ...(ngDevMode ? [{ debugName: "#blurTick" }] : /* istanbul ignore next */ []));
1056
1283
  /**
1057
1284
  * Computed signal that returns field paths for all touched (or submitted) leaf controls.
1058
1285
  * Updates reactively when controls are touched (blur) or when form status changes.
@@ -1066,7 +1293,7 @@ class FormDirective {
1066
1293
  this.#blurTick();
1067
1294
  this.#statusSignal();
1068
1295
  return this.#collectTouchedPaths(this.ngForm.form, this.ngForm.submitted);
1069
- }, ...(ngDevMode ? [{ debugName: "touchedFieldPaths" }] : []));
1296
+ }, ...(ngDevMode ? [{ debugName: "touchedFieldPaths" }] : /* istanbul ignore next */ []));
1070
1297
  /**
1071
1298
  * Computed signal for form state with validity and errors.
1072
1299
  * Used by templates and tests as vestForm.formState().valid/errors
@@ -1083,7 +1310,7 @@ class FormDirective {
1083
1310
  errors: getAllFormErrors(this.ngForm.form),
1084
1311
  value: this.#formValueSignal(),
1085
1312
  };
1086
- }, { ...(ngDevMode ? { debugName: "formState" } : {}), equal: (a, b) => {
1313
+ }, { ...(ngDevMode ? { debugName: "formState" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1087
1314
  // Fast path: reference equality
1088
1315
  if (a === b)
1089
1316
  return true;
@@ -1092,29 +1319,29 @@ class FormDirective {
1092
1319
  return false;
1093
1320
  // Deep equality check for form state properties
1094
1321
  return (a.valid === b.valid &&
1095
- fastDeepEqual(a.errors, b.errors) &&
1096
- fastDeepEqual(a.value, b.value));
1322
+ this.#equal(a.errors, b.errors) &&
1323
+ this.#equal(a.value, b.value));
1097
1324
  } });
1098
1325
  /**
1099
1326
  * The value of the form, this is needed for the validation part.
1100
1327
  * Using input() here because two-way binding is provided via formValueChange output.
1101
1328
  * In the minimal core directive (form-core.directive.ts), this would be model() instead.
1102
1329
  */
1103
- this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
1330
+ this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : /* istanbul ignore next */ []));
1104
1331
  /**
1105
1332
  * Static vest suite that will be used to feed our angular validators.
1106
1333
  * Accepts both NgxVestSuite and NgxTypedVestSuite through compatible type signatures.
1107
1334
  * NgxTypedVestSuite<T> is assignable to NgxVestSuite<T> due to bivariance and
1108
1335
  * FormFieldName<T> (string literal union) being assignable to string.
1109
1336
  */
1110
- this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
1337
+ this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : /* istanbul ignore next */ []));
1111
1338
  /**
1112
1339
  * The shape of our form model. This is a deep required version of the form model
1113
1340
  * The goal is to add default values to the shape so when the template-driven form
1114
1341
  * contains values that shouldn't be there (typo's) that the developer gets run-time
1115
1342
  * errors in dev mode
1116
1343
  */
1117
- this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : []));
1344
+ this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : /* istanbul ignore next */ []));
1118
1345
  /**
1119
1346
  * Updates the validation config which is a dynamic object that will be used to
1120
1347
  * trigger validations on the dependant fields
@@ -1128,7 +1355,7 @@ class FormDirective {
1128
1355
  *
1129
1356
  * @param v
1130
1357
  */
1131
- this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : []));
1358
+ this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : /* istanbul ignore next */ []));
1132
1359
  /**
1133
1360
  * Emits whenever validation feedback may have changed, even if the aggregate
1134
1361
  * root form status string stays the same.
@@ -1159,7 +1386,7 @@ class FormDirective {
1159
1386
  */
1160
1387
  this.formValueChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof ValueChangeEvent), map((v) => v.value), distinctUntilChanged((prev, curr) => {
1161
1388
  // Use efficient deep equality instead of JSON.stringify for better performance
1162
- return fastDeepEqual(prev, curr);
1389
+ return this.#equal(prev, curr);
1163
1390
  }), map(() => mergeValuesAndRawValues(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
1164
1391
  /**
1165
1392
  * Emits an object with all the errors of the form
@@ -1187,11 +1414,18 @@ class FormDirective {
1187
1414
  * Cleanup is handled automatically by the directive when it's destroyed.
1188
1415
  */
1189
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();
1190
1423
  /**
1191
1424
  * Track validation in progress to prevent circular triggering (Issue #19)
1192
1425
  */
1193
1426
  this.validationInProgress = new Set();
1194
1427
  this.destroyRef.onDestroy(() => {
1428
+ this.#destroyed = true;
1195
1429
  this.fieldWarnings.set(new Map());
1196
1430
  });
1197
1431
  /**
@@ -1227,8 +1461,8 @@ class FormDirective {
1227
1461
  if (!formValue && !modelValue)
1228
1462
  return;
1229
1463
  // Compute change flags first
1230
- const formChanged = !fastDeepEqual(formValue, this.#lastSyncedFormValue);
1231
- const modelChanged = !fastDeepEqual(modelValue, this.#lastSyncedModelValue);
1464
+ const formChanged = !this.#equal(formValue, this.#lastSyncedFormValue);
1465
+ const modelChanged = !this.#equal(modelValue, this.#lastSyncedModelValue);
1232
1466
  // Early return if nothing changed
1233
1467
  if (!formChanged && !modelChanged) {
1234
1468
  return;
@@ -1262,7 +1496,7 @@ class FormDirective {
1262
1496
  else if (formChanged && modelChanged) {
1263
1497
  // Both form and model changed simultaneously
1264
1498
  // Check if they changed to the same value (synchronized change) or different values (conflict)
1265
- const valuesEqual = fastDeepEqual(formValue, modelValue);
1499
+ const valuesEqual = this.#equal(formValue, modelValue);
1266
1500
  if (valuesEqual) {
1267
1501
  // Both changed to the same value - this is a synchronized change, not a conflict
1268
1502
  // Just update tracking to acknowledge the change
@@ -1494,13 +1728,98 @@ class FormDirective {
1494
1728
  * Host handler: called whenever any descendant field loses focus.
1495
1729
  * Used to make touched-path tracking react immediately on blur/tab.
1496
1730
  */
1497
- onFormFocusOut() {
1731
+ onFormFocusOut(event) {
1498
1732
  // Run on the next microtask to ensure Angular has already applied
1499
1733
  // control.touched changes for the field that just blurred.
1500
- queueMicrotask(() => {
1734
+ scheduleMicrotask(() => {
1501
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,
1502
1770
  });
1503
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
+ }
1504
1823
  /**
1505
1824
  * Resets the form to a pristine, untouched state with optional new values.
1506
1825
  *
@@ -1561,7 +1880,6 @@ class FormDirective {
1561
1880
  // is treated as a model change (not a conflict with stale form values)
1562
1881
  this.#lastSyncedFormValue = null;
1563
1882
  this.#lastSyncedModelValue = null;
1564
- this.#lastLinkedValue = null;
1565
1883
  // Force change detection to ensure DOM updates are reflected
1566
1884
  // Note: This is still needed even with signals because we're modifying NgForm
1567
1885
  // (reactive forms), not signals. The formValue signal updates happen in the
@@ -1602,6 +1920,18 @@ class FormDirective {
1602
1920
  // Both NgxVestSuite and NgxTypedVestSuite work with string at runtime
1603
1921
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1604
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
+ }
1605
1935
  const errors = result.getErrors()[field];
1606
1936
  const warnings = result.getWarnings()[field];
1607
1937
  // Store warnings in the fieldWarnings signal for access by control wrappers.
@@ -1638,8 +1968,9 @@ class FormDirective {
1638
1968
  // visual state (even though the control status has updated).
1639
1969
  //
1640
1970
  // We schedule a detectChanges() on the next microtask to avoid calling it
1641
- // synchronously inside Angular's own validation pipeline.
1642
- 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(() => {
1643
1974
  try {
1644
1975
  this.cdr.detectChanges();
1645
1976
  }
@@ -1648,7 +1979,7 @@ class FormDirective {
1648
1979
  // This keeps behavior resilient in edge cases.
1649
1980
  this.cdr.markForCheck();
1650
1981
  }
1651
- });
1982
+ }, this.destroyRef);
1652
1983
  observer.next(out);
1653
1984
  observer.complete();
1654
1985
  });
@@ -1687,10 +2018,7 @@ class FormDirective {
1687
2018
  this.validationInProgress.clear();
1688
2019
  return EMPTY;
1689
2020
  }
1690
- const streams = Object.keys(config).map((triggerField) => {
1691
- const dependents = config[triggerField] || [];
1692
- return this.#createTriggerStream(form, triggerField, dependents);
1693
- });
2021
+ const streams = Object.entries(config).map(([triggerField, dependents]) => this.#createTriggerStream(form, triggerField, dependents || []));
1694
2022
  return streams.length > 0 ? merge(...streams) : EMPTY;
1695
2023
  }
1696
2024
  /**
@@ -1805,20 +2133,6 @@ class FormDirective {
1805
2133
  // CRITICAL: Mark the dependent field as in-progress BEFORE calling updateValueAndValidity
1806
2134
  // This prevents the dependent field's valueChanges from triggering its own validationConfig
1807
2135
  this.validationInProgress.add(depField);
1808
- // NOTE: Touch propagation removed (PR #78)
1809
- // Previously, we propagated touch state from trigger to dependent fields.
1810
- // This caused UX issues where dependent fields showed errors immediately
1811
- // after being revealed by a toggle, even though the user never interacted with them.
1812
- //
1813
- // With this change:
1814
- // - Errors on dependent fields only show after the user directly touches/blurs them
1815
- // - ARIA attributes (aria-invalid) still work correctly via isInvalid check
1816
- // - Warnings still show after validation via hasBeenValidated check
1817
- //
1818
- // The removed code was:
1819
- // if (control.touched && !dependentControl.touched) {
1820
- // dependentControl.markAsTouched({ onlySelf: true });
1821
- // }
1822
2136
  // emitEvent: true is REQUIRED for async validators to actually run
1823
2137
  // The validationInProgress Set prevents infinite loops:
1824
2138
  // 1. Field A changes → triggers validation on dependent field B
@@ -1842,13 +2156,14 @@ class FormDirective {
1842
2156
  }
1843
2157
  }
1844
2158
  // Keep fields marked as in-progress for a short time to prevent immediate re-triggering
1845
- // Use setTimeout to ensure async validators have time to complete before allowing new triggers
1846
- 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(() => {
1847
2162
  this.validationInProgress.delete(triggerField);
1848
2163
  for (const depField of dependents) {
1849
2164
  this.validationInProgress.delete(depField);
1850
2165
  }
1851
- }, VALIDATION_IN_PROGRESS_TIMEOUT_MS);
2166
+ }, VALIDATION_IN_PROGRESS_TIMEOUT_MS, this.destroyRef);
1852
2167
  }
1853
2168
  /**
1854
2169
  * Collects field paths of all touched (or submitted) leaf controls
@@ -1876,19 +2191,180 @@ class FormDirective {
1876
2191
  collect(control, []);
1877
2192
  return fields;
1878
2193
  }
1879
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1880
- 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 }); }
1881
2196
  }
1882
- 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: [{
1883
2198
  type: Directive,
1884
2199
  args: [{
1885
2200
  selector: 'form[scVestForm], form[ngxVestForm]',
1886
2201
  exportAs: 'scVestForm, ngxVestForm',
1887
2202
  host: {
1888
- '(focusout)': 'onFormFocusOut()',
2203
+ '(focusout)': 'onFormFocusOut($event)',
1889
2204
  },
1890
2205
  }]
1891
- }], 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
+ }
1892
2368
 
1893
2369
  const INITIAL_FORM_CONTROL_STATE = {
1894
2370
  status: 'INVALID',
@@ -1929,9 +2405,22 @@ class FormControlStateDirective {
1929
2405
  * Internal signal for control state (updated reactively)
1930
2406
  */
1931
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;
1932
2421
  constructor() {
1933
- this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
1934
- 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 */ []));
1935
2424
  this.#hostNgModel = inject(NgModel, { self: true, optional: true });
1936
2425
  this.#hostNgModelGroup = inject(NgModelGroup, {
1937
2426
  self: true,
@@ -1950,7 +2439,7 @@ class FormControlStateDirective {
1950
2439
  this.#hostNgModelGroup ||
1951
2440
  this.contentNgModel() ||
1952
2441
  this.contentNgModelGroup() ||
1953
- null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
2442
+ null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : /* istanbul ignore next */ []));
1954
2443
  /**
1955
2444
  * Consolidated internal signal for interaction state tracking.
1956
2445
  * Combines touched, dirty, and hasBeenValidated into a single signal
@@ -1960,7 +2449,7 @@ class FormControlStateDirective {
1960
2449
  isTouched: false,
1961
2450
  isDirty: false,
1962
2451
  hasBeenValidated: false,
1963
- }, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
2452
+ }, ...(ngDevMode ? [{ debugName: "#interactionState" }] : /* istanbul ignore next */ []));
1964
2453
  /**
1965
2454
  * Track the previous status to detect actual status changes (not just status emissions).
1966
2455
  * This helps distinguish between initial control creation and actual re-validation.
@@ -1969,11 +2458,24 @@ class FormControlStateDirective {
1969
2458
  /**
1970
2459
  * Internal signal for control state (updated reactively)
1971
2460
  */
1972
- 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;
1973
2475
  /**
1974
2476
  * Main control state computed signal (merges robust touched/dirty)
1975
2477
  */
1976
- this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
2478
+ this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : /* istanbul ignore next */ []));
1977
2479
  /**
1978
2480
  * Extracts error messages from Angular/Vest errors (recursively flattens)
1979
2481
  */
@@ -1993,20 +2495,20 @@ class FormControlStateDirective {
1993
2495
  const errorsWithoutWarnings = { ...errors };
1994
2496
  delete errorsWithoutWarnings['warnings'];
1995
2497
  return this.#flattenAngularErrors(errorsWithoutWarnings);
1996
- }, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
2498
+ }, ...(ngDevMode ? [{ debugName: "errorMessages" }] : /* istanbul ignore next */ []));
1997
2499
  /**
1998
2500
  * ADVANCED: updateOn strategy (change/blur/submit) if available
1999
2501
  */
2000
2502
  this.updateOn = computed(() => {
2001
2503
  const ngModel = this.contentNgModel() || this.#hostNgModel;
2002
2504
  return ngModel?.options?.updateOn ?? 'change';
2003
- }, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
2505
+ }, ...(ngDevMode ? [{ debugName: "updateOn" }] : /* istanbul ignore next */ []));
2004
2506
  /**
2005
2507
  * ADVANCED: Composite/derived signals for advanced error display logic
2006
2508
  */
2007
- this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
2008
- this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
2009
- 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 */ []));
2010
2512
  /**
2011
2513
  * Extracts warning messages from Vest validation results.
2012
2514
  * Checks two sources:
@@ -2041,36 +2543,59 @@ class FormControlStateDirective {
2041
2543
  }
2042
2544
  }
2043
2545
  return [];
2044
- }, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
2546
+ }, ...(ngDevMode ? [{ debugName: "warningMessages" }] : /* istanbul ignore next */ []));
2045
2547
  /**
2046
2548
  * Whether async validation is in progress
2047
2549
  */
2048
- this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
2550
+ this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : /* istanbul ignore next */ []));
2049
2551
  /**
2050
2552
  * Convenience signals for common state checks
2051
2553
  */
2052
- this.isValid = computed(() => this.controlState().isValid, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
2053
- this.isInvalid = computed(() => this.controlState().isInvalid, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
2054
- this.isPending = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
2055
- this.isTouched = computed(() => this.controlState().isTouched, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
2056
- this.isDirty = computed(() => this.controlState().isDirty, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
2057
- this.isPristine = computed(() => this.controlState().isPristine, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
2058
- this.isDisabled = computed(() => this.controlState().isDisabled, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
2059
- 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 */ []));
2060
2562
  /**
2061
2563
  * Whether this control has been validated at least once.
2062
2564
  * True after the first validation completes, even if the user hasn't touched the field.
2063
- * 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.
2064
2567
  */
2065
- this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : []));
2568
+ this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : /* istanbul ignore next */ []));
2066
2569
  // Update control state reactively with proper cleanup
2067
2570
  effect((onCleanup) => {
2068
2571
  const control = this.#activeControl();
2069
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
+ }
2070
2584
  if (!control) {
2071
2585
  this.#controlStateSignal.set(INITIAL_FORM_CONTROL_STATE);
2072
2586
  return;
2073
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
+ }
2074
2599
  // Listen to control changes
2075
2600
  const sub = control.control?.statusChanges?.subscribe(() => {
2076
2601
  const { status, valid, invalid, pending, disabled, pristine, errors, touched, } = control;
@@ -2168,6 +2693,23 @@ class FormControlStateDirective {
2168
2693
  ? true
2169
2694
  : state.hasBeenValidated,
2170
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
+ });
2171
2713
  }
2172
2714
  // Sync pending state only when it transitions from true to false
2173
2715
  // This fixes "Validating..." being stuck when statusChanges misses the transition
@@ -2218,10 +2760,10 @@ class FormControlStateDirective {
2218
2760
  }
2219
2761
  return result;
2220
2762
  }
2221
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2222
- 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 }); }
2223
2765
  }
2224
- 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: [{
2225
2767
  type: Directive,
2226
2768
  args: [{
2227
2769
  selector: '[formControlState], [ngxControlState]',
@@ -2278,13 +2820,13 @@ class FormErrorDisplayDirective {
2278
2820
  */
2279
2821
  this.errorDisplayMode = input(inject(NGX_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
2280
2822
  inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
2281
- SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
2823
+ SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : /* istanbul ignore next */ []));
2282
2824
  /**
2283
2825
  * Input signal for warning display mode.
2284
2826
  * Controls whether warnings are shown only after touch or also after validation.
2285
2827
  */
2286
2828
  this.warningDisplayMode = input(inject(NGX_WARNING_DISPLAY_MODE_TOKEN, { optional: true }) ??
2287
- SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : []));
2829
+ SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : /* istanbul ignore next */ []));
2288
2830
  // Expose state signals from FormControlStateDirective
2289
2831
  this.controlState = this.#formControlState.controlState;
2290
2832
  this.errorMessages = this.#formControlState.errorMessages;
@@ -2309,20 +2851,25 @@ class FormErrorDisplayDirective {
2309
2851
  * This keeps programmatic `NgForm.onSubmit()` reactive in zoneless mode and
2310
2852
  * avoids depending on `NgForm.submitted`, whose getter intentionally reads an
2311
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`).
2312
2859
  */
2313
2860
  this.formSubmitted = this.#formSubmittedState;
2314
2861
  /**
2315
2862
  * Determines if errors should be shown based on the specified display mode
2316
- * and the control's state (touched/submitted/validated).
2863
+ * and the control's state (touched/submitted/dirty).
2317
2864
  *
2318
2865
  * Note: We check both hasErrors (extracted error messages) AND isInvalid (Angular's validation state)
2319
2866
  * because in some cases (like conditional validations via validationConfig), the control is marked
2320
2867
  * as invalid by Angular before error messages are extracted from Vest. This ensures aria-invalid
2321
2868
  * is set correctly even during the validation propagation delay.
2322
2869
  *
2323
- * For validationConfig-triggered validations: A field can be validated without being touched
2324
- * (e.g., confirmPassword validated when password changes). We check hasBeenValidated to show
2325
- * 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.
2326
2873
  */
2327
2874
  this.shouldShowErrors = computed(() => {
2328
2875
  const mode = this.errorDisplayMode();
@@ -2358,7 +2905,7 @@ class FormErrorDisplayDirective {
2358
2905
  // Show after blur (touch) OR submit (default behavior)
2359
2906
  return !!((isTouched || formSubmitted) && hasErrorState);
2360
2907
  }
2361
- }, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
2908
+ }, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : /* istanbul ignore next */ []));
2362
2909
  /**
2363
2910
  * Errors to display (filtered for pending state)
2364
2911
  */
@@ -2366,7 +2913,7 @@ class FormErrorDisplayDirective {
2366
2913
  if (this.hasPendingValidation())
2367
2914
  return [];
2368
2915
  return this.errorMessages();
2369
- }, ...(ngDevMode ? [{ debugName: "errors" }] : []));
2916
+ }, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
2370
2917
  /**
2371
2918
  * Warnings to display (filtered for pending state)
2372
2919
  */
@@ -2374,7 +2921,7 @@ class FormErrorDisplayDirective {
2374
2921
  if (this.hasPendingValidation())
2375
2922
  return [];
2376
2923
  return this.warningMessages();
2377
- }, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
2924
+ }, ...(ngDevMode ? [{ debugName: "warnings" }] : /* istanbul ignore next */ []));
2378
2925
  /**
2379
2926
  * Whether the control is currently being validated (pending)
2380
2927
  * Excludes pristine+untouched controls to prevent "Validating..." on initial load
@@ -2387,7 +2934,7 @@ class FormErrorDisplayDirective {
2387
2934
  return false;
2388
2935
  }
2389
2936
  return this.hasPendingValidation();
2390
- }, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
2937
+ }, ...(ngDevMode ? [{ debugName: "isPending" }] : /* istanbul ignore next */ []));
2391
2938
  /**
2392
2939
  * Determines if warnings should be shown based on the specified display mode
2393
2940
  * and the control's state (touched/validated/dirty).
@@ -2428,7 +2975,7 @@ class FormErrorDisplayDirective {
2428
2975
  // Show after validation runs or after touch/submit (default behavior)
2429
2976
  return hasBeenValidated || isTouched || formSubmitted;
2430
2977
  }
2431
- }, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : []));
2978
+ }, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : /* istanbul ignore next */ []));
2432
2979
  const ngForm = this.#ngForm;
2433
2980
  if (ngForm) {
2434
2981
  ngForm.form.events
@@ -2448,10 +2995,10 @@ class FormErrorDisplayDirective {
2448
2995
  }
2449
2996
  });
2450
2997
  }
2451
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2452
- 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 }); }
2453
3000
  }
2454
- 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: [{
2455
3002
  type: Directive,
2456
3003
  args: [{
2457
3004
  selector: '[formErrorDisplay], [ngxErrorDisplay]',
@@ -2536,9 +3083,17 @@ function resolveAssociationTargets(controls, mode) {
2536
3083
  * @returns Object containing the debounced showPendingMessage signal and cleanup function
2537
3084
  */
2538
3085
  function createDebouncedPendingState(isPending, options = {}) {
2539
- 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;
2540
3095
  // Create writable signal for debounced state
2541
- const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : []));
3096
+ const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : /* istanbul ignore next */ []));
2542
3097
  // Track timeouts
2543
3098
  let pendingTimeout = null;
2544
3099
  let minimumDisplayTimeout = null;
@@ -2556,6 +3111,9 @@ function createDebouncedPendingState(isPending, options = {}) {
2556
3111
  // Effect to manage debounced pending message display
2557
3112
  effect((onCleanup) => {
2558
3113
  const pending = isPending();
3114
+ const current = optionsSignal ? optionsSignal() : staticOptions;
3115
+ const showAfter = current.showAfter ?? 200;
3116
+ const minimumDisplay = current.minimumDisplay ?? 500;
2559
3117
  if (pending) {
2560
3118
  // Clear any existing minimum display timeout
2561
3119
  if (minimumDisplayTimeout) {
@@ -2740,16 +3298,16 @@ class ControlWrapperComponent {
2740
3298
  * across multiple child controls.
2741
3299
  * - This does not affect whether messages render; it only affects ARIA wiring.
2742
3300
  */
2743
- this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
3301
+ this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : /* istanbul ignore next */ []));
2744
3302
  // Generate unique IDs for ARIA associations
2745
3303
  this.uniqueId = `ngx-control-wrapper-${nextUniqueId$2++}`;
2746
3304
  this.errorId = `${this.uniqueId}-error`;
2747
3305
  this.warningId = `${this.uniqueId}-warning`;
2748
3306
  this.pendingId = `${this.uniqueId}-pending`;
2749
3307
  // Track form controls found in the wrapper
2750
- this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
3308
+ this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : /* istanbul ignore next */ []));
2751
3309
  // Signals when content is initialized so effects can safely touch the DOM.
2752
- this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
3310
+ this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : /* istanbul ignore next */ []));
2753
3311
  // MutationObserver to detect dynamically added/removed controls
2754
3312
  this.mutationObserver = null;
2755
3313
  /**
@@ -2781,7 +3339,7 @@ class ControlWrapperComponent {
2781
3339
  ids.push(this.pendingId);
2782
3340
  }
2783
3341
  return ids.length > 0 ? ids.join(' ') : null;
2784
- }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
3342
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : /* istanbul ignore next */ []));
2785
3343
  /**
2786
3344
  * IDs managed by this wrapper when composing aria-describedby.
2787
3345
  *
@@ -2874,10 +3432,10 @@ class ControlWrapperComponent {
2874
3432
  const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
2875
3433
  this.formControls.set(Array.from(controls));
2876
3434
  }
2877
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2878
- 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 }); }
2879
3437
  }
2880
- 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: [{
2881
3439
  type: Component,
2882
3440
  args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2883
3441
  class: 'ngx-control-wrapper sc-control-wrapper',
@@ -2913,12 +3471,12 @@ class FormGroupWrapperComponent {
2913
3471
  this.pendingDebounce = input({
2914
3472
  showAfter: 500,
2915
3473
  minimumDisplay: 500,
2916
- }, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : []));
3474
+ }, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : /* istanbul ignore next */ []));
2917
3475
  this.uniqueId = `ngx-form-group-wrapper-${nextUniqueId$1++}`;
2918
3476
  this.errorId = `${this.uniqueId}-error`;
2919
3477
  this.warningId = `${this.uniqueId}-warning`;
2920
3478
  this.pendingId = `${this.uniqueId}-pending`;
2921
- this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce());
3479
+ this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce);
2922
3480
  this.showPendingMessage = this.pendingState.showPendingMessage;
2923
3481
  /**
2924
3482
  * Helpful if consumers want to wire aria-describedby manually (e.g. fieldset/legend pattern).
@@ -2935,12 +3493,12 @@ class FormGroupWrapperComponent {
2935
3493
  ids.push(this.pendingId);
2936
3494
  }
2937
3495
  return ids.length > 0 ? ids.join(' ') : null;
2938
- }, ...(ngDevMode ? [{ debugName: "describedByIds" }] : []));
3496
+ }, ...(ngDevMode ? [{ debugName: "describedByIds" }] : /* istanbul ignore next */ []));
2939
3497
  }
2940
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2941
- 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 }); }
2942
3500
  }
2943
- 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: [{
2944
3502
  type: Component,
2945
3503
  args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper]', exportAs: 'ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2946
3504
  class: 'ngx-form-group-wrapper sc-form-group-wrapper',
@@ -2977,7 +3535,7 @@ class FormErrorControlDirective {
2977
3535
  * - `single-control`: apply ARIA attributes only when exactly one control is found.
2978
3536
  * - `none`: do not mutate descendant controls.
2979
3537
  */
2980
- this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
3538
+ this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : /* istanbul ignore next */ []));
2981
3539
  /**
2982
3540
  * Unique ID prefix for this instance.
2983
3541
  * Use these IDs to render message regions and to support aria-describedby.
@@ -2986,8 +3544,8 @@ class FormErrorControlDirective {
2986
3544
  this.errorId = `${this.uniqueId}-error`;
2987
3545
  this.warningId = `${this.uniqueId}-warning`;
2988
3546
  this.pendingId = `${this.uniqueId}-pending`;
2989
- this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
2990
- 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 */ []));
2991
3549
  this.mutationObserver = null;
2992
3550
  this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
2993
3551
  this.showPendingMessage = this.pendingState.showPendingMessage;
@@ -3006,7 +3564,7 @@ class FormErrorControlDirective {
3006
3564
  ids.push(this.pendingId);
3007
3565
  }
3008
3566
  return ids.length > 0 ? ids.join(' ') : null;
3009
- }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
3567
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : /* istanbul ignore next */ []));
3010
3568
  this.ownedDescribedByIds = [
3011
3569
  this.errorId,
3012
3570
  this.warningId,
@@ -3080,10 +3638,10 @@ class FormErrorControlDirective {
3080
3638
  const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
3081
3639
  this.formControls.set(Array.from(controls));
3082
3640
  }
3083
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3084
- 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 }); }
3085
3643
  }
3086
- 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: [{
3087
3645
  type: Directive,
3088
3646
  args: [{
3089
3647
  selector: '[formErrorControl], [ngxErrorControl]',
@@ -3147,7 +3705,7 @@ class FormModelGroupDirective {
3147
3705
  *
3148
3706
  * Defaults to no debounce (`{ debounceTime: 0 }`).
3149
3707
  */
3150
- this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
3708
+ this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
3151
3709
  this.formDirective = inject(FormDirective, { optional: true });
3152
3710
  }
3153
3711
  /**
@@ -3163,8 +3721,8 @@ class FormModelGroupDirective {
3163
3721
  return getFormGroupField(context.ngForm.control, currentControl);
3164
3722
  }, this.validationOptions(), 'FormModelGroupDirective');
3165
3723
  }
3166
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3167
- 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: [
3168
3726
  {
3169
3727
  provide: NG_ASYNC_VALIDATORS,
3170
3728
  useExisting: FormModelGroupDirective,
@@ -3172,7 +3730,7 @@ class FormModelGroupDirective {
3172
3730
  },
3173
3731
  ], ngImport: i0 }); }
3174
3732
  }
3175
- 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: [{
3176
3734
  type: Directive,
3177
3735
  args: [{
3178
3736
  selector: '[ngModelGroup],[ngxModelGroup]',
@@ -3197,7 +3755,7 @@ class FormModelDirective {
3197
3755
  *
3198
3756
  * Defaults to no debounce (`{ debounceTime: 0 }`).
3199
3757
  */
3200
- this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
3758
+ this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
3201
3759
  /**
3202
3760
  * Reference to the form that needs to be validated
3203
3761
  * Injected optionally so that using ngModel outside of an ngxVestForm
@@ -3220,8 +3778,8 @@ class FormModelDirective {
3220
3778
  return getFormControlField(context.ngForm.control, currentControl);
3221
3779
  }, this.validationOptions(), 'FormModelDirective');
3222
3780
  }
3223
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3224
- 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: [
3225
3783
  {
3226
3784
  provide: NG_ASYNC_VALIDATORS,
3227
3785
  useExisting: FormModelDirective,
@@ -3229,7 +3787,7 @@ class FormModelDirective {
3229
3787
  },
3230
3788
  ], ngImport: i0 }); }
3231
3789
  }
3232
- 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: [{
3233
3791
  type: Directive,
3234
3792
  args: [{
3235
3793
  selector: '[ngModel],[ngxModel]',
@@ -3318,26 +3876,30 @@ class ValidateRootFormDirective {
3318
3876
  constructor() {
3319
3877
  this.injector = inject(Injector);
3320
3878
  this.destroyRef = inject(DestroyRef);
3321
- this.lastControl = signal(null, ...(ngDevMode ? [{ debugName: "lastControl" }] : []));
3322
- this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
3323
- this.hasSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "hasSubmitted" }] : []));
3324
- this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
3325
- 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 */ []));
3326
3884
  /**
3327
3885
  * Whether the root form should be validated or not
3328
3886
  * This will use the field rootForm
3329
3887
  * Accepts both validateRootForm and ngxValidateRootForm
3330
3888
  */
3331
- this.validateRootForm = input(false, { ...(ngDevMode ? { debugName: "validateRootForm" } : {}), transform: booleanAttribute });
3332
- 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 });
3333
3891
  /**
3334
3892
  * Validation mode:
3335
- * - 'submit' (default): Only validates after form submission
3336
- * - 'live': Validates on every value change
3337
- * 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.
3338
3900
  */
3339
- this.validateRootFormMode = input('submit', ...(ngDevMode ? [{ debugName: "validateRootFormMode" }] : []));
3340
- 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 */ []));
3341
3903
  // Convert signals to Observables in injection context
3342
3904
  this.hasSubmitted$ = toObservable(this.hasSubmitted);
3343
3905
  this.formValue$ = toObservable(this.formValue);
@@ -3362,7 +3924,9 @@ class ValidateRootFormDirective {
3362
3924
  if (ngForm?.control) {
3363
3925
  // Defer to the next microtask so Angular has a chance to finish
3364
3926
  // wiring up controls/groups (ngModel/ngModelGroup) on initial render.
3365
- 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);
3366
3930
  }
3367
3931
  });
3368
3932
  }
@@ -3384,7 +3948,7 @@ class ValidateRootFormDirective {
3384
3948
  // Ensure we run at least one validation pass after the form is ready.
3385
3949
  // This matters for 'live' mode root-form errors that should appear
3386
3950
  // without requiring a user interaction.
3387
- queueMicrotask(() => ngForm.control.updateValueAndValidity());
3951
+ scheduleMicrotask(() => ngForm.control.updateValueAndValidity(), this.destroyRef);
3388
3952
  // Subscribe to form submission to set hasSubmitted flag
3389
3953
  ngForm.ngSubmit
3390
3954
  .pipe(tap(() => {
@@ -3402,10 +3966,12 @@ class ValidateRootFormDirective {
3402
3966
  if (!isEnabled) {
3403
3967
  return of(null);
3404
3968
  }
3405
- // Get mode from either input (ngx prefix takes precedence if both set)
3406
- const mode = this.ngxValidateRootFormMode() !== 'submit'
3407
- ? this.ngxValidateRootFormMode()
3408
- : 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';
3409
3975
  // In 'submit' mode, skip validation until form is submitted
3410
3976
  if (mode === 'submit' && !this.hasSubmitted()) {
3411
3977
  return of(null);
@@ -3466,8 +4032,8 @@ class ValidateRootFormDirective {
3466
4032
  }), take(1), takeUntilDestroyed(this.destroyRef));
3467
4033
  };
3468
4034
  }
3469
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3470
- 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: [
3471
4037
  {
3472
4038
  provide: NG_ASYNC_VALIDATORS,
3473
4039
  useExisting: ValidateRootFormDirective,
@@ -3475,7 +4041,7 @@ class ValidateRootFormDirective {
3475
4041
  },
3476
4042
  ], ngImport: i0 }); }
3477
4043
  }
3478
- 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: [{
3479
4045
  type: Directive,
3480
4046
  args: [{
3481
4047
  selector: 'form[validateRootForm], form[ngxValidateRootForm]',
@@ -3702,7 +4268,7 @@ class ValidationConfigBuilder {
3702
4268
  */
3703
4269
  whenChanged(trigger, revalidate) {
3704
4270
  const deps = Array.isArray(revalidate) ? revalidate : [revalidate];
3705
- const existing = this.config[trigger] || [];
4271
+ const existing = (this.config[trigger] || []);
3706
4272
  // Development mode warning for duplicate dependents
3707
4273
  if (typeof ngDevMode !== 'undefined' && ngDevMode) {
3708
4274
  const duplicates = deps.filter((d) => existing.includes(d));
@@ -4324,7 +4890,7 @@ function keepFieldsWhen(currentState, conditions) {
4324
4890
  const result = {};
4325
4891
  for (const fieldName of Object.keys(conditions)) {
4326
4892
  const shouldKeep = conditions[fieldName];
4327
- if (shouldKeep && fieldName in currentState) {
4893
+ if (shouldKeep && Object.hasOwn(currentState, fieldName)) {
4328
4894
  result[fieldName] = currentState[fieldName];
4329
4895
  }
4330
4896
  }
@@ -4339,5 +4905,5 @@ function keepFieldsWhen(currentState, conditions) {
4339
4905
  * Generated bundle index. Do not edit.
4340
4906
  */
4341
4907
 
4342
- 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 };
4343
4909
  //# sourceMappingURL=ngx-vest-forms.mjs.map