ngx-vest-forms 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, isDevMode, inject, DestroyRef, ChangeDetectorRef, signal, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender, ElementRef, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
3
- import { FormGroup, FormArray, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, NgModel, NgModelGroup, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
3
+ import { isFormArray, isFormGroup, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, FormGroup, FormArray, NgModel, NgModelGroup, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
4
4
  import { toSignal, outputFromObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
5
5
  import { startWith, filter, map, distinctUntilChanged, merge, switchMap, take, of, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
6
6
 
@@ -326,8 +326,6 @@ function fastDeepEqual(obj1, obj2, maxDepth = 10) {
326
326
  return true;
327
327
  }
328
328
 
329
- const ROOT_FORM = 'rootForm';
330
-
331
329
  /**
332
330
  * Utilities for working with field paths in dot/bracket notation and Standard Schema path arrays.
333
331
  *
@@ -346,6 +344,16 @@ const ROOT_FORM = 'rootForm';
346
344
  * // Returns: 'user.addresses[0].street'
347
345
  * ```
348
346
  */
347
+ const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
348
+ /**
349
+ * @internal
350
+ * Returns whether a path segment is unsafe for object writes/merges.
351
+ *
352
+ * Unsafe segments are blocked to prevent prototype pollution vectors.
353
+ */
354
+ function isUnsafePathSegment(segment) {
355
+ return typeof segment === 'string' && UNSAFE_PATH_SEGMENTS.has(segment);
356
+ }
349
357
  /**
350
358
  * @internal
351
359
  * Internal utility for parsing field path strings.
@@ -435,37 +443,44 @@ function stringifyFieldPath(path) {
435
443
  return result;
436
444
  }
437
445
 
438
- /* eslint-disable @typescript-eslint/no-explicit-any */
446
+ const ROOT_FORM = 'rootForm';
447
+
448
+ const ERROR_MESSAGES_KEY = 'errors';
449
+ const WARNING_MESSAGES_KEY = 'warnings';
450
+ function isRecord(value) {
451
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
452
+ }
439
453
  /**
440
454
  * Recursively calculates the path of a form control
441
455
  * @param formGroup
442
456
  * @param control
443
457
  */
458
+ function getChildEntries(container) {
459
+ if (isFormArray(container)) {
460
+ return container.controls.map((child, index) => [String(index), child]);
461
+ }
462
+ return Object.entries(container.controls);
463
+ }
444
464
  function getControlPath(formGroup, control) {
445
465
  // First attempt: depth-first traversal from provided root
446
- for (const key in formGroup.controls) {
447
- if (Object.prototype.hasOwnProperty.call(formGroup.controls, key)) {
448
- const child = formGroup.get(key);
449
- if (child === control) {
450
- return key;
451
- }
452
- if (child instanceof FormGroup || child instanceof FormArray) {
453
- const subPath = getControlPath(child, control);
454
- if (subPath) {
455
- return `${key}.${subPath}`;
456
- }
466
+ for (const [key, child] of getChildEntries(formGroup)) {
467
+ if (child === control) {
468
+ return key;
469
+ }
470
+ if (isFormGroup(child) || isFormArray(child)) {
471
+ const subPath = getControlPath(child, control);
472
+ if (subPath) {
473
+ return `${key}.${subPath}`;
457
474
  }
458
475
  }
459
476
  }
460
477
  // Fallback: walk up the parent chain from control to root
461
478
  let current = control;
462
479
  const segments = [];
463
- while (current && current.parent) {
480
+ while (current?.parent) {
464
481
  const parent = current.parent;
465
- if (!parent?.controls)
466
- break;
467
- for (const key of Object.keys(parent.controls)) {
468
- if (parent.controls[key] === current) {
482
+ for (const [key, controlInParent] of getChildEntries(parent)) {
483
+ if (controlInParent === current) {
469
484
  segments.unshift(key);
470
485
  break;
471
486
  }
@@ -477,7 +492,10 @@ function getControlPath(formGroup, control) {
477
492
  }
478
493
  // Last resort: try control.name if available
479
494
  const name = control.name;
480
- return name ?? '';
495
+ if (typeof name === 'string') {
496
+ return name;
497
+ }
498
+ return '';
481
499
  }
482
500
  /**
483
501
  * Recursively calculates the path of a form group
@@ -485,12 +503,11 @@ function getControlPath(formGroup, control) {
485
503
  * @param control
486
504
  */
487
505
  function getGroupPath(formGroup, control) {
488
- for (const key of Object.keys(formGroup.controls)) {
489
- const ctrl = formGroup.get(key);
506
+ for (const [key, ctrl] of getChildEntries(formGroup)) {
490
507
  if (ctrl === control) {
491
508
  return key;
492
509
  }
493
- if (ctrl instanceof FormGroup) {
510
+ if (isFormGroup(ctrl)) {
494
511
  const path = getGroupPath(ctrl, control);
495
512
  if (path) {
496
513
  return `${key}.${path}`;
@@ -536,8 +553,11 @@ function getFormGroupField(rootForm, control) {
536
553
  * to include disabled field values in form submissions. Use Angular's `getRawValue()`
537
554
  * method on your form if you need to access disabled field values.
538
555
  *
539
- * This RxJS operator merges the value of the form with the raw value.
556
+ * This utility merges the value of the form with the raw value.
540
557
  * By doing this we can assure that we don't lose values of disabled form fields
558
+ *
559
+ * Security: Unsafe prototype-related keys (`__proto__`, `prototype`, `constructor`)
560
+ * are skipped during recursive merge.
541
561
  * @param form
542
562
  */
543
563
  function mergeValuesAndRawValues(form) {
@@ -554,15 +574,18 @@ function mergeValuesAndRawValues(form) {
554
574
  // Recursive function to merge rawValue into value
555
575
  function mergeRecursive(target, source) {
556
576
  for (const key of Object.keys(source)) {
557
- if (target[key] === undefined) {
577
+ if (isUnsafePathSegment(key)) {
578
+ continue;
579
+ }
580
+ const sourceValue = source[key];
581
+ const targetValue = target[key];
582
+ if (targetValue === undefined) {
558
583
  // If the key is not in the target, add it directly (for disabled fields)
559
- target[key] = source[key];
584
+ target[key] = sourceValue;
560
585
  }
561
- else if (typeof source[key] === 'object' &&
562
- source[key] !== null &&
563
- !Array.isArray(source[key])) {
586
+ else if (isRecord(sourceValue) && isRecord(targetValue)) {
564
587
  // If the value is an object, merge it recursively
565
- mergeRecursive(target[key], source[key]);
588
+ mergeRecursive(targetValue, sourceValue);
566
589
  }
567
590
  // If the target already has the key with a primitive value, it's left as is to maintain references
568
591
  }
@@ -573,6 +596,12 @@ function mergeValuesAndRawValues(form) {
573
596
  function isPrimitive(value) {
574
597
  return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
575
598
  }
599
+ function getStringArrayError(errors, key) {
600
+ const value = errors?.[key];
601
+ return Array.isArray(value)
602
+ ? value.filter((v) => typeof v === 'string')
603
+ : undefined;
604
+ }
576
605
  /**
577
606
  * Performs a deep-clone of an object
578
607
  * @param obj
@@ -608,22 +637,44 @@ function cloneDeep(object) {
608
637
  throw new Error("Unable to copy object! Its type isn't supported.");
609
638
  }
610
639
  /**
611
- * Sets a value in an object in the correct path
612
- * @param obj
613
- * @param path
614
- * @param value
640
+ * Sets a value in an object at the provided field path.
641
+ *
642
+ * Supports dot and bracket notation via `parseFieldPath()`.
643
+ * Examples: `user.profile.name`, `addresses[0].street`.
644
+ *
645
+ * Security: If any path segment matches an unsafe prototype-related key
646
+ * (`__proto__`, `prototype`, `constructor`), the write is ignored.
647
+ *
648
+ * @param obj - Target object to mutate.
649
+ * @param path - Dot/bracket field path.
650
+ * @param value - Value to assign at the resolved path.
615
651
  */
616
652
  function setValueAtPath(obj, path, value) {
617
- const keys = path.split('.');
653
+ const keys = parseFieldPath(path);
654
+ if (keys.length === 0) {
655
+ return;
656
+ }
618
657
  let current = obj;
619
658
  for (let i = 0; i < keys.length - 1; i++) {
620
- const key = keys[i];
621
- if (!current[key]) {
659
+ const segment = keys[i];
660
+ if (segment === undefined) {
661
+ continue;
662
+ }
663
+ if (isUnsafePathSegment(segment)) {
664
+ return;
665
+ }
666
+ const key = String(segment);
667
+ const next = current[key];
668
+ if (!isRecord(next)) {
622
669
  current[key] = {};
623
670
  }
624
671
  current = current[key];
625
672
  }
626
- current[keys[keys.length - 1]] = value;
673
+ const lastSegment = keys[keys.length - 1];
674
+ if (lastSegment === undefined || isUnsafePathSegment(lastSegment)) {
675
+ return;
676
+ }
677
+ current[String(lastSegment)] = value;
627
678
  }
628
679
  /**
629
680
  * @deprecated Use {@link setValueAtPath} instead
@@ -648,9 +699,10 @@ function getAllFormErrors(form) {
648
699
  return errors;
649
700
  }
650
701
  // Collect root form errors (from ValidateRootFormDirective) before processing children
651
- if (form.errors && form.enabled) {
652
- if (form.errors['errors'] && Array.isArray(form.errors['errors'])) {
653
- errors[ROOT_FORM] = form.errors['errors'];
702
+ if (form.enabled) {
703
+ const rootErrors = getStringArrayError(form.errors, ERROR_MESSAGES_KEY);
704
+ if (rootErrors) {
705
+ errors[ROOT_FORM] = rootErrors;
654
706
  }
655
707
  }
656
708
  function collect(control, pathParts) {
@@ -658,48 +710,44 @@ function getAllFormErrors(form) {
658
710
  // Skip processing the root form control directly in NgxFormDirective
659
711
  if (pathParts.length === 0 && control === form) {
660
712
  // Instead, iterate its children if it's a group/array
661
- if (control instanceof FormGroup || control instanceof FormArray) {
662
- for (const key of Object.keys(control.controls)) {
663
- const childControl = control.get(key);
713
+ if (isFormGroup(control) || isFormArray(control)) {
714
+ for (const [key, childControl] of getChildEntries(control)) {
715
+ const numericKey = Number(key);
664
716
  const nextPath = [
665
717
  // ...pathParts, // pathParts is empty here
666
- Number.isNaN(Number(key)) ? key : Number(key),
718
+ Number.isNaN(numericKey) ? key : numericKey,
667
719
  ];
668
- if (childControl) {
669
- collect(childControl, nextPath);
670
- }
720
+ collect(childControl, nextPath);
671
721
  }
672
722
  }
673
723
  return; // Stop processing for the root form itself at this level
674
724
  }
675
- if (control instanceof FormGroup || control instanceof FormArray) {
676
- for (const key of Object.keys(control.controls)) {
677
- const childControl = control.get(key);
725
+ if (isFormGroup(control) || isFormArray(control)) {
726
+ for (const [key, childControl] of getChildEntries(control)) {
727
+ const numericKey = Number(key);
678
728
  const nextPath = [
679
729
  ...pathParts,
680
- Number.isNaN(Number(key)) ? key : Number(key),
730
+ Number.isNaN(numericKey) ? key : numericKey,
681
731
  ];
682
- if (childControl) {
683
- collect(childControl, nextPath);
684
- }
732
+ collect(childControl, nextPath);
685
733
  }
686
734
  }
687
735
  // Attach control errors (both errors and warnings)
688
- if (control.errors && control.enabled) {
689
- // If errors is an array, assign it
690
- if (control.errors['errors'] && Array.isArray(control.errors['errors'])) {
691
- errors[pathString] = control.errors['errors'];
736
+ if (control.enabled) {
737
+ const fieldErrors = getStringArrayError(control.errors, ERROR_MESSAGES_KEY);
738
+ if (fieldErrors) {
739
+ errors[pathString] = fieldErrors;
692
740
  }
693
741
  // Optionally, add warnings if present
694
- if (control.errors['warnings'] &&
695
- Array.isArray(control.errors['warnings'])) {
742
+ const fieldWarnings = getStringArrayError(control.errors, WARNING_MESSAGES_KEY);
743
+ if (fieldWarnings) {
696
744
  // Attach warnings as a property on the error array (non-enumerable)
697
745
  // This is still done here for field-specific warnings, but not for root warnings.
698
746
  if (!errors[pathString]) {
699
747
  errors[pathString] = []; // Ensure array exists if only warnings are present
700
748
  }
701
749
  Object.defineProperty(errors[pathString], 'warnings', {
702
- value: control.errors['warnings'],
750
+ value: fieldWarnings,
703
751
  enumerable: false, // Keep it non-enumerable as per previous behavior for field warnings
704
752
  configurable: true,
705
753
  writable: true,
@@ -833,6 +881,12 @@ class FormDirective {
833
881
  * Track the Angular form status as a signal for advanced status flags
834
882
  */
835
883
  #statusSignal;
884
+ /**
885
+ * Reactive counter incremented on any focusout within the form.
886
+ * This guarantees recomputation for every blur/tab interaction,
887
+ * even when the form's aggregate touched flag is already true.
888
+ */
889
+ #blurTick;
836
890
  constructor() {
837
891
  this.ngForm = inject(NgForm, { self: true });
838
892
  this.destroyRef = inject(DestroyRef);
@@ -872,6 +926,26 @@ class FormDirective {
872
926
  * Track the Angular form status as a signal for advanced status flags
873
927
  */
874
928
  this.#statusSignal = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
929
+ /**
930
+ * Reactive counter incremented on any focusout within the form.
931
+ * This guarantees recomputation for every blur/tab interaction,
932
+ * even when the form's aggregate touched flag is already true.
933
+ */
934
+ this.#blurTick = signal(0, ...(ngDevMode ? [{ debugName: "#blurTick" }] : []));
935
+ /**
936
+ * Computed signal that returns field paths for all touched (or submitted) leaf controls.
937
+ * Updates reactively when controls are touched (blur) or when form status changes.
938
+ *
939
+ * This enables consumers to determine which fields the user has interacted with,
940
+ * useful for filtering errors/warnings to match the form's visible validation state.
941
+ *
942
+ * @publicApi
943
+ */
944
+ this.touchedFieldPaths = computed(() => {
945
+ this.#blurTick();
946
+ this.#statusSignal();
947
+ return this.#collectTouchedPaths(this.ngForm.form, this.ngForm.submitted);
948
+ }, ...(ngDevMode ? [{ debugName: "touchedFieldPaths" }] : []));
875
949
  /**
876
950
  * Computed signal for form state with validity and errors.
877
951
  * Used by templates and tests as vestForm.formState().valid/errors
@@ -1013,6 +1087,7 @@ class FormDirective {
1013
1087
  .pipe(takeUntilDestroyed(this.destroyRef))
1014
1088
  .subscribe(() => {
1015
1089
  this.ngForm.form.markAllAsTouched();
1090
+ this.#blurTick.update((v) => v + 1);
1016
1091
  });
1017
1092
  /**
1018
1093
  * Single bidirectional synchronization effect using linkedSignal.
@@ -1201,6 +1276,18 @@ class FormDirective {
1201
1276
  */
1202
1277
  markAllAsTouched() {
1203
1278
  this.ngForm.form.markAllAsTouched();
1279
+ this.#blurTick.update((v) => v + 1);
1280
+ }
1281
+ /**
1282
+ * Host handler: called whenever any descendant field loses focus.
1283
+ * Used to make touched-path tracking react immediately on blur/tab.
1284
+ */
1285
+ onFormFocusOut() {
1286
+ // Run on the next microtask to ensure Angular has already applied
1287
+ // control.touched changes for the field that just blurred.
1288
+ queueMicrotask(() => {
1289
+ this.#blurTick.update((v) => v + 1);
1290
+ });
1204
1291
  }
1205
1292
  /**
1206
1293
  * Resets the form to a pristine, untouched state with optional new values.
@@ -1272,17 +1359,18 @@ class FormDirective {
1272
1359
  // Trigger validation update to clear any stale errors
1273
1360
  // Now synchronous since detectChanges() has flushed DOM updates
1274
1361
  this.ngForm.form.updateValueAndValidity({ emitEvent: true });
1362
+ this.#blurTick.update((v) => v + 1);
1275
1363
  }
1276
1364
  /**
1277
- * This will feed the formValueCache, debounce it till the next tick
1278
- * and create an asynchronous validator that runs a vest suite
1279
- * @param field
1280
- * @param validationOptions
1281
- * @returns an asynchronous validator function
1282
- */
1283
- /**
1284
- * V2 async validator pattern: uses timer() + switchMap for proper re-validation.
1285
- * Each invocation builds a fresh one-shot observable, ensuring progressive validation.
1365
+ * Creates a one-shot async validator function for a specific field path.
1366
+ *
1367
+ * The returned validator:
1368
+ * - snapshots the current form model,
1369
+ * - injects the candidate control value at `field`,
1370
+ * - runs the Vest suite with debouncing,
1371
+ * - maps Vest errors/warnings into Angular `ValidationErrors | null`.
1372
+ *
1373
+ * Warnings are stored in `fieldWarnings` to keep warnings non-blocking when no errors exist.
1286
1374
  */
1287
1375
  createAsyncValidator(field, validationOptions) {
1288
1376
  const suite = this.suite();
@@ -1402,7 +1490,6 @@ class FormDirective {
1402
1490
  * 3. Waiting for form to be idle before triggering dependents
1403
1491
  * 4. Waiting for all dependent controls to exist
1404
1492
  * 5. Updating dependent field validity with loop prevention
1405
- * 6. Touch state propagation from trigger to dependents
1406
1493
  *
1407
1494
  * @param form - The NgForm instance
1408
1495
  * @param triggerField - Field path that triggers validation (e.g., 'password')
@@ -1444,7 +1531,11 @@ class FormDirective {
1444
1531
  }
1445
1532
  // Form is PENDING, wait for it to become idle
1446
1533
  const idle$ = form.statusChanges.pipe(filter((s) => s !== 'PENDING'), take(1));
1447
- const timeout$ = timer(2000);
1534
+ const timeout$ = timer(2000).pipe(tap(() => {
1535
+ if (isDevMode()) {
1536
+ console.warn('[ngx-vest-forms] validationConfig: timed out waiting for form to leave PENDING state (2s). Continuing dependent validation to avoid stalling.');
1537
+ }
1538
+ }));
1448
1539
  return race(idle$, timeout$).pipe(map(() => control));
1449
1540
  }
1450
1541
  /**
@@ -1461,16 +1552,28 @@ class FormDirective {
1461
1552
  if (allDependentsExist) {
1462
1553
  return of(control);
1463
1554
  }
1464
- // Wait for dependent controls to be added to the form
1465
- return form.statusChanges.pipe(startWith(form.status), filter(() => dependents.every((depField) => !!form.get(depField))), take(1), map(() => control));
1555
+ // Wait for dependent controls to be added to the form, but bound the wait to avoid silent stalls.
1556
+ const dependentControlsReady$ = form.statusChanges.pipe(startWith(form.status), filter(() => dependents.every((depField) => !!form.get(depField))), take(1), map(() => control));
1557
+ const timeout$ = timer(2000).pipe(tap(() => {
1558
+ if (isDevMode()) {
1559
+ const unresolved = dependents.filter((depField) => !form.get(depField));
1560
+ console.warn(`[ngx-vest-forms] validationConfig: timed out waiting for dependent controls (2s): ${unresolved.join(', ')}. Continuing without waiting further.`);
1561
+ }
1562
+ }), map(() => control));
1563
+ return race(dependentControlsReady$, timeout$).pipe(take(1));
1466
1564
  }
1467
1565
  /**
1468
1566
  * Updates validation for all dependent fields.
1469
1567
  *
1470
1568
  * Handles:
1471
- * - Touch state propagation (mark dependents touched when trigger is touched)
1472
1569
  * - Loop prevention via validationInProgress set
1473
- * - Silent validation (emitEvent: false) to prevent feedback loops
1570
+ * - Silent validation updates that avoid feedback loops
1571
+ *
1572
+ * Note: Touch state is NOT propagated to prevent premature error display
1573
+ * on conditionally revealed fields.
1574
+ *
1575
+ * Note: This method does NOT propagate touch state from trigger to dependent fields.
1576
+ * Dependent fields only show errors after the user directly interacts with them.
1474
1577
  *
1475
1578
  * @param form - The NgForm instance
1476
1579
  * @param control - The trigger control
@@ -1490,11 +1593,20 @@ class FormDirective {
1490
1593
  // CRITICAL: Mark the dependent field as in-progress BEFORE calling updateValueAndValidity
1491
1594
  // This prevents the dependent field's valueChanges from triggering its own validationConfig
1492
1595
  this.validationInProgress.add(depField);
1493
- // Propagate touch state BEFORE validation to avoid duplicate class updates
1494
- // markAsTouched() triggers change detection, so we do it once before updateValueAndValidity
1495
- if (control.touched && !dependentControl.touched) {
1496
- dependentControl.markAsTouched({ onlySelf: true });
1497
- }
1596
+ // NOTE: Touch propagation removed (PR #78)
1597
+ // Previously, we propagated touch state from trigger to dependent fields.
1598
+ // This caused UX issues where dependent fields showed errors immediately
1599
+ // after being revealed by a toggle, even though the user never interacted with them.
1600
+ //
1601
+ // With this change:
1602
+ // - Errors on dependent fields only show after the user directly touches/blurs them
1603
+ // - ARIA attributes (aria-invalid) still work correctly via isInvalid check
1604
+ // - Warnings still show after validation via hasBeenValidated check
1605
+ //
1606
+ // The removed code was:
1607
+ // if (control.touched && !dependentControl.touched) {
1608
+ // dependentControl.markAsTouched({ onlySelf: true });
1609
+ // }
1498
1610
  // emitEvent: true is REQUIRED for async validators to actually run
1499
1611
  // The validationInProgress Set prevents infinite loops:
1500
1612
  // 1. Field A changes → triggers validation on dependent field B
@@ -1526,14 +1638,43 @@ class FormDirective {
1526
1638
  }
1527
1639
  }, VALIDATION_IN_PROGRESS_TIMEOUT_MS);
1528
1640
  }
1529
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1530
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormDirective, isStandalone: true, selector: "form[scVestForm], form[ngxVestForm]", inputs: { formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, formShape: { classPropertyName: "formShape", publicName: "formShape", isSignal: true, isRequired: false, transformFunction: null }, validationConfig: { classPropertyName: "validationConfig", publicName: "validationConfig", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formValueChange: "formValueChange", errorsChange: "errorsChange", dirtyChange: "dirtyChange", validChange: "validChange" }, exportAs: ["scVestForm", "ngxVestForm"], ngImport: i0 }); }
1641
+ /**
1642
+ * Collects field paths of all touched (or submitted) leaf controls
1643
+ * by walking the form control tree.
1644
+ */
1645
+ #collectTouchedPaths(control, submitted) {
1646
+ const fields = [];
1647
+ const collect = (current, path) => {
1648
+ if (current instanceof FormGroup) {
1649
+ for (const [name, child] of Object.entries(current.controls)) {
1650
+ collect(child, [...path, name]);
1651
+ }
1652
+ return;
1653
+ }
1654
+ if (current instanceof FormArray) {
1655
+ current.controls.forEach((child, index) => {
1656
+ collect(child, [...path, index]);
1657
+ });
1658
+ return;
1659
+ }
1660
+ if ((submitted || current.touched) && path.length > 0) {
1661
+ fields.push(stringifyFieldPath(path));
1662
+ }
1663
+ };
1664
+ collect(control, []);
1665
+ return fields;
1666
+ }
1667
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1668
+ 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 }); }
1531
1669
  }
1532
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormDirective, decorators: [{
1670
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormDirective, decorators: [{
1533
1671
  type: Directive,
1534
1672
  args: [{
1535
1673
  selector: 'form[scVestForm], form[ngxVestForm]',
1536
1674
  exportAs: 'scVestForm, ngxVestForm',
1675
+ host: {
1676
+ '(focusout)': 'onFormFocusOut()',
1677
+ },
1537
1678
  }]
1538
1679
  }], 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"] }] } });
1539
1680
 
@@ -1720,19 +1861,21 @@ class FormControlStateDirective {
1720
1861
  }
1721
1862
  // Listen to control changes
1722
1863
  const sub = control.control?.statusChanges?.subscribe(() => {
1723
- const { status, valid, invalid, pending, disabled, pristine, errors, touched, dirty, } = control;
1864
+ const { status, valid, invalid, pending, disabled, pristine, errors, touched, } = control;
1724
1865
  const currentStatus = status;
1725
1866
  // Mark as validated when any of the following conditions are met:
1726
1867
  // 1. The control has been touched (user blurred the field).
1727
1868
  // 2. The control's status has actually changed (not the first status emission),
1728
- // AND the new status is not 'PENDING' (validation completed),
1729
- // AND the control has been interacted with (dirty).
1869
+ // AND the new status is not 'PENDING' (validation completed).
1730
1870
  //
1731
1871
  // This ensures hasBeenValidated is true for:
1732
1872
  // - User blur events (touched becomes true)
1733
- // - User-triggered validations (dirty)
1734
- // - ValidationConfig-triggered validations that result in the control becoming touched
1735
- // But NOT for initial page load validations.
1873
+ // - Any validation that changes status (e.g., typing then validation completes)
1874
+ // - ValidationConfig-triggered validations (status changed without touch/dirty)
1875
+ // But NOT for initial page load validations (previousStatus === null).
1876
+ //
1877
+ // Note: The dirty check was removed to support validationConfig-triggered validations.
1878
+ // This allows warnings to show even when the field hasn't been touched/dirtied by the user.
1736
1879
  //
1737
1880
  // Accessibility: The logic is structured for clarity and maintainability.
1738
1881
  // IMPORTANT: Read touched/dirty directly from control, not from signal,
@@ -1741,8 +1884,7 @@ class FormControlStateDirective {
1741
1884
  (this.#previousStatus !== null && // Not the first status emission
1742
1885
  this.#previousStatus !== currentStatus && // Status actually changed
1743
1886
  currentStatus !== null &&
1744
- currentStatus !== 'PENDING' &&
1745
- dirty) // Or control value changed (typed)
1887
+ currentStatus !== 'PENDING') // Validation completed (not pending)
1746
1888
  ) {
1747
1889
  this.#interactionState.update((state) => ({
1748
1890
  ...state,
@@ -1796,13 +1938,23 @@ class FormControlStateDirective {
1796
1938
  // Mark as validated when control becomes touched (e.g., user blurred the field)
1797
1939
  // This handles the case where blur doesn't trigger statusChanges (field already invalid)
1798
1940
  const shouldMarkValidated = newTouched && !currentInteraction.isTouched;
1941
+ // Reset hasBeenValidated when a control transitions back to pristine interaction state
1942
+ // (e.g., after form.resetForm()).
1943
+ //
1944
+ // This prevents stale warning display on untouched fields after reset while preserving
1945
+ // validationConfig-triggered behavior during normal interaction.
1946
+ const wasResetToPristineInteraction = !newTouched &&
1947
+ !newDirty &&
1948
+ (currentInteraction.isTouched || currentInteraction.isDirty);
1799
1949
  this.#interactionState.update((state) => ({
1800
1950
  ...state,
1801
1951
  isTouched: newTouched,
1802
1952
  isDirty: newDirty,
1803
- hasBeenValidated: shouldMarkValidated
1804
- ? true
1805
- : state.hasBeenValidated,
1953
+ hasBeenValidated: wasResetToPristineInteraction
1954
+ ? false
1955
+ : shouldMarkValidated
1956
+ ? true
1957
+ : state.hasBeenValidated,
1806
1958
  }));
1807
1959
  }
1808
1960
  // Sync pending state only when it transitions from true to false
@@ -1854,10 +2006,10 @@ class FormControlStateDirective {
1854
2006
  }
1855
2007
  return result;
1856
2008
  }
1857
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1858
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.1", type: FormControlStateDirective, isStandalone: true, selector: "[formControlState], [ngxControlState]", queries: [{ propertyName: "contentNgModel", first: true, predicate: NgModel, descendants: true, isSignal: true }, { propertyName: "contentNgModelGroup", first: true, predicate: NgModelGroup, descendants: true, isSignal: true }], exportAs: ["formControlState", "ngxControlState"], ngImport: i0 }); }
2009
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2010
+ 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 }); }
1859
2011
  }
1860
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormControlStateDirective, decorators: [{
2012
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormControlStateDirective, decorators: [{
1861
2013
  type: Directive,
1862
2014
  args: [{
1863
2015
  selector: '[formControlState], [ngxControlState]',
@@ -2086,10 +2238,10 @@ class FormErrorDisplayDirective {
2086
2238
  }
2087
2239
  });
2088
2240
  }
2089
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2090
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormErrorDisplayDirective, isStandalone: true, selector: "[formErrorDisplay], [ngxErrorDisplay]", inputs: { errorDisplayMode: { classPropertyName: "errorDisplayMode", publicName: "errorDisplayMode", isSignal: true, isRequired: false, transformFunction: null }, warningDisplayMode: { classPropertyName: "warningDisplayMode", publicName: "warningDisplayMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorDisplay", "ngxErrorDisplay"], hostDirectives: [{ directive: FormControlStateDirective }], ngImport: i0 }); }
2241
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2242
+ 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 }); }
2091
2243
  }
2092
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
2244
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
2093
2245
  type: Directive,
2094
2246
  args: [{
2095
2247
  selector: '[formErrorDisplay], [ngxErrorDisplay]',
@@ -2098,6 +2250,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
2098
2250
  }]
2099
2251
  }], ctorParameters: () => [], propDecorators: { errorDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorDisplayMode", required: false }] }], warningDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "warningDisplayMode", required: false }] }] } });
2100
2252
 
2253
+ /**
2254
+ * Splits an `aria-describedby` attribute value into normalized token IDs.
2255
+ */
2256
+ function parseAriaIdTokens(value) {
2257
+ return (value ?? '')
2258
+ .split(/\s+/)
2259
+ .map((token) => token.trim())
2260
+ .filter(Boolean);
2261
+ }
2262
+ /**
2263
+ * Merges currently-active wrapper IDs into an existing `aria-describedby` value.
2264
+ *
2265
+ * Existing tokens owned by the wrapper are removed first, then current active IDs
2266
+ * are appended while preserving non-owned tokens and token uniqueness.
2267
+ */
2268
+ function mergeAriaDescribedBy(existing, activeIds, ownedIds) {
2269
+ const ownedIdSet = new Set(ownedIds);
2270
+ const existingTokens = parseAriaIdTokens(existing);
2271
+ const existingWithoutOwned = existingTokens.filter((token) => !ownedIdSet.has(token));
2272
+ const merged = [...existingWithoutOwned];
2273
+ const mergedIdSet = new Set(merged);
2274
+ for (const id of activeIds) {
2275
+ if (!mergedIdSet.has(id)) {
2276
+ merged.push(id);
2277
+ mergedIdSet.add(id);
2278
+ }
2279
+ }
2280
+ return merged.length > 0 ? merged.join(' ') : null;
2281
+ }
2282
+ /**
2283
+ * Resolves control targets based on ARIA association mode.
2284
+ */
2285
+ function resolveAssociationTargets(controls, mode) {
2286
+ if (mode === 'none') {
2287
+ return [];
2288
+ }
2289
+ if (mode === 'single-control') {
2290
+ return controls.length === 1 ? [...controls] : [];
2291
+ }
2292
+ return [...controls];
2293
+ }
2294
+
2101
2295
  /**
2102
2296
  * Creates a debounced pending state signal that prevents flashing validation messages.
2103
2297
  *
@@ -2201,7 +2395,7 @@ function createDebouncedPendingState(isPending, options = {}) {
2201
2395
  // Counter for unique IDs
2202
2396
  let nextUniqueId$2 = 0;
2203
2397
  /**
2204
- * Accessible form control wrapper with WCAG 2.2 AA compliance.
2398
+ * Accessible form control wrapper built with WCAG 2.2 AA considerations.
2205
2399
  *
2206
2400
  * Wrap form fields to automatically display validation errors, warnings, and pending states
2207
2401
  * with proper accessibility attributes.
@@ -2292,7 +2486,7 @@ let nextUniqueId$2 = 0;
2292
2486
  * <ngx-control-wrapper>
2293
2487
  * <input name="username" ngModel />
2294
2488
  * </ngx-control-wrapper>
2295
- * /// If async validation is running for >200ms, a spinner and 'Validating…' will be shown.
2489
+ * /// If async validation is running for >500ms, a spinner and 'Validating…' will be shown.
2296
2490
  * /// Once shown, the validation message stays visible for minimum 500ms to prevent flashing.
2297
2491
  * /// If Vest warnings are present, they will be shown below errors.
2298
2492
  *
@@ -2301,13 +2495,14 @@ let nextUniqueId$2 = 0;
2301
2495
  * import { NGX_ERROR_DISPLAY_MODE_TOKEN } from 'ngx-vest-forms';
2302
2496
  * @Component({
2303
2497
  * providers: [
2304
- * provide(NGX_ERROR_DISPLAY_MODE_TOKEN, { useValue: 'submit' })
2498
+ * provide(NGX_ERROR_DISPLAY_MODE_TOKEN, { useValue: 'on-submit' })
2305
2499
  * ]
2306
2500
  * })
2307
2501
  * export class MyComponent {}
2308
2502
  *
2309
2503
  * Best Practices:
2310
- * - Use for every input or group in your forms.
2504
+ * - Use for single-control wrappers.
2505
+ * - For multi-control/group containers, prefer `ngx-form-group-wrapper`.
2311
2506
  * - Do not manually display errors for individual fields; rely on this wrapper.
2312
2507
  * - Validate with tools like Accessibility Insights and real screen reader testing.
2313
2508
  *
@@ -2315,22 +2510,6 @@ let nextUniqueId$2 = 0;
2315
2510
  * @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - ARIA22: Using role=status
2316
2511
  */
2317
2512
  class ControlWrapperComponent {
2318
- mergeAriaDescribedBy(existing, wrapperActiveIds) {
2319
- const existingTokens = (existing ?? '')
2320
- .split(/\s+/)
2321
- .map((t) => t.trim())
2322
- .filter(Boolean);
2323
- // Remove any previous wrapper-owned IDs from the existing list.
2324
- const existingWithoutWrapper = existingTokens.filter((t) => !this.wrapperOwnedDescribedByIds.includes(t));
2325
- // Append current wrapper IDs, preserving existing order and uniqueness.
2326
- const merged = [...existingWithoutWrapper];
2327
- for (const id of wrapperActiveIds) {
2328
- if (!merged.includes(id)) {
2329
- merged.push(id);
2330
- }
2331
- }
2332
- return merged.length > 0 ? merged.join(' ') : null;
2333
- }
2334
2513
  constructor() {
2335
2514
  this.errorDisplay = inject(FormErrorDisplayDirective, {
2336
2515
  self: true,
@@ -2414,20 +2593,12 @@ class ControlWrapperComponent {
2414
2593
  return;
2415
2594
  }
2416
2595
  const describedBy = this.ariaDescribedBy();
2417
- const wrapperActiveIds = describedBy
2418
- ? describedBy.split(/\s+/).filter(Boolean)
2419
- : [];
2596
+ const wrapperActiveIds = parseAriaIdTokens(describedBy);
2420
2597
  const shouldShowErrors = this.errorDisplay.shouldShowErrors();
2421
- const targets = (() => {
2422
- const controls = this.formControls();
2423
- if (mode === 'single-control') {
2424
- return controls.length === 1 ? controls : [];
2425
- }
2426
- return controls;
2427
- })();
2598
+ const targets = resolveAssociationTargets(this.formControls(), mode);
2428
2599
  targets.forEach((control) => {
2429
2600
  // Update aria-describedby (merge, don't overwrite)
2430
- const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), wrapperActiveIds);
2601
+ const nextDescribedBy = mergeAriaDescribedBy(control.getAttribute('aria-describedby'), wrapperActiveIds, this.wrapperOwnedDescribedByIds);
2431
2602
  if (nextDescribedBy) {
2432
2603
  control.setAttribute('aria-describedby', nextDescribedBy);
2433
2604
  }
@@ -2493,10 +2664,10 @@ class ControlWrapperComponent {
2493
2664
  const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
2494
2665
  this.formControls.set(Array.from(controls));
2495
2666
  }
2496
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2497
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.1", type: ControlWrapperComponent, isStandalone: true, selector: "ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-control-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-control-wrapper sc-control-wrapper" }, hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode", "warningDisplayMode", "warningDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n WCAG 2.2 AA Compliance for Inline Field Validation:\n\n IMPORTANT DISTINCTION - Field-level vs Form-level errors:\n - FIELD-LEVEL errors (this component): Use role=\"status\" with aria-live=\"polite\"\n These are inline validation messages that appear as users interact with fields.\n Using \"assertive\" would be too disruptive when users are filling out multiple fields.\n - FORM-LEVEL blocking errors (e.g., submission failed): Should use role=\"alert\" with\n aria-live=\"assertive\" in a separate form-level error summary component.\n\n This component handles FIELD-LEVEL messages:\n - Errors: role=\"status\" with aria-live=\"polite\" (non-disruptive, field is already\n marked with aria-invalid=\"true\" and aria-describedby points here)\n - Warnings: role=\"status\" with aria-live=\"polite\" (informational, doesn't block)\n - Pending: role=\"status\" with aria-live=\"polite\" (status update)\n\n The aria-invalid=\"true\" + aria-describedby association on the input provides\n the primary accessibility pathway; the live region is supplementary.\n\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA21 - Using aria-invalid\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - Using role=status\n-->\n<div\n [id]=\"errorId\"\n class=\"text-sm text-red-600\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.shouldShowErrors()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<!--\n Warnings: Non-blocking validation messages (Vest warn())\n - Use role=\"status\" with aria-live=\"polite\" per WCAG ARIA22\n - Default: show after validation runs or touch; configurable to require touch only\n - Warnings do NOT affect field validity - they're informational only\n-->\n<div\n [id]=\"warningId\"\n class=\"text-sm text-yellow-700\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (shouldShowWarnings()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<!-- Pending state is also stable; content appears only after the debounce delay -->\n<div\n [id]=\"pendingId\"\n class=\"absolute top-0 right-0 flex items-center gap-1 text-xs text-gray-500\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (showPendingMessage()) {\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n aria-hidden=\"true\"\n ></span>\n Validating\u2026\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
2667
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2668
+ 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 }); }
2498
2669
  }
2499
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ControlWrapperComponent, decorators: [{
2670
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ControlWrapperComponent, decorators: [{
2500
2671
  type: Component,
2501
2672
  args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2502
2673
  class: 'ngx-control-wrapper sc-control-wrapper',
@@ -2547,7 +2718,7 @@ class FormGroupWrapperComponent {
2547
2718
  if (this.errorDisplay.shouldShowErrors()) {
2548
2719
  ids.push(this.errorId);
2549
2720
  }
2550
- if (this.errorDisplay.warnings().length > 0) {
2721
+ if (this.errorDisplay.shouldShowWarnings()) {
2551
2722
  ids.push(this.warningId);
2552
2723
  }
2553
2724
  if (this.showPendingMessage()) {
@@ -2556,21 +2727,21 @@ class FormGroupWrapperComponent {
2556
2727
  return ids.length > 0 ? ids.join(' ') : null;
2557
2728
  }, ...(ngDevMode ? [{ debugName: "describedByIds" }] : []));
2558
2729
  }
2559
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2560
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.1", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]", inputs: { pendingDebounce: { classPropertyName: "pendingDebounce", publicName: "pendingDebounce", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-form-group-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-form-group-wrapper sc-form-group-wrapper" }, exportAs: ["formGroupWrapper", "ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-form-group-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Keep regions stable in the DOM so IDs are always valid targets.\n This wrapper does NOT modify descendant controls.\n-->\n<div [id]=\"errorId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowErrors() && errorDisplay.errors().length > 0) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"warningId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"pendingId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (showPendingMessage()) {\n <span aria-hidden=\"true\">Validating\u2026</span>\n }\n</div>\n", styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
2730
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2731
+ 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 }); }
2561
2732
  }
2562
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
2733
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
2563
2734
  type: Component,
2564
- args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]', exportAs: 'formGroupWrapper, ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2735
+ args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper]', exportAs: 'ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2565
2736
  class: 'ngx-form-group-wrapper sc-form-group-wrapper',
2566
2737
  '[class.ngx-form-group-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
2567
2738
  '[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
2568
2739
  }, hostDirectives: [
2569
2740
  {
2570
2741
  directive: FormErrorDisplayDirective,
2571
- inputs: ['errorDisplayMode'],
2742
+ inputs: ['errorDisplayMode', 'warningDisplayMode'],
2572
2743
  },
2573
- ], template: "<div class=\"ngx-form-group-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Keep regions stable in the DOM so IDs are always valid targets.\n This wrapper does NOT modify descendant controls.\n-->\n<div [id]=\"errorId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowErrors() && errorDisplay.errors().length > 0) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"warningId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.warnings().length > 0) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"pendingId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (showPendingMessage()) {\n <span aria-hidden=\"true\">Validating\u2026</span>\n }\n</div>\n", styles: [":host{display:block}\n"] }]
2744
+ ], 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"] }]
2574
2745
  }], propDecorators: { pendingDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingDebounce", required: false }] }] } });
2575
2746
 
2576
2747
  let nextUniqueId = 0;
@@ -2584,20 +2755,6 @@ let nextUniqueId = 0;
2584
2755
  * It does not render any UI; you can use the generated IDs to render messages.
2585
2756
  */
2586
2757
  class FormErrorControlDirective {
2587
- mergeAriaDescribedBy(existing, activeIds) {
2588
- const existingTokens = (existing ?? '')
2589
- .split(/\s+/)
2590
- .map((t) => t.trim())
2591
- .filter(Boolean);
2592
- const existingWithoutOwned = existingTokens.filter((t) => !this.ownedDescribedByIds.includes(t));
2593
- const merged = [...existingWithoutOwned];
2594
- for (const id of activeIds) {
2595
- if (!merged.includes(id)) {
2596
- merged.push(id);
2597
- }
2598
- }
2599
- return merged.length > 0 ? merged.join(' ') : null;
2600
- }
2601
2758
  constructor() {
2602
2759
  this.errorDisplay = inject(FormErrorDisplayDirective, {
2603
2760
  self: true,
@@ -2632,7 +2789,7 @@ class FormErrorControlDirective {
2632
2789
  if (this.errorDisplay.shouldShowErrors()) {
2633
2790
  ids.push(this.errorId);
2634
2791
  }
2635
- if (this.errorDisplay.warnings().length > 0) {
2792
+ if (this.errorDisplay.shouldShowWarnings()) {
2636
2793
  ids.push(this.warningId);
2637
2794
  }
2638
2795
  if (this.showPendingMessage()) {
@@ -2653,19 +2810,11 @@ class FormErrorControlDirective {
2653
2810
  if (mode === 'none')
2654
2811
  return;
2655
2812
  const describedBy = this.ariaDescribedBy();
2656
- const activeIds = describedBy
2657
- ? describedBy.split(/\s+/).filter(Boolean)
2658
- : [];
2813
+ const activeIds = parseAriaIdTokens(describedBy);
2659
2814
  const shouldShowErrors = this.errorDisplay.shouldShowErrors();
2660
- const targets = (() => {
2661
- const controls = this.formControls();
2662
- if (mode === 'single-control') {
2663
- return controls.length === 1 ? controls : [];
2664
- }
2665
- return controls;
2666
- })();
2815
+ const targets = resolveAssociationTargets(this.formControls(), mode);
2667
2816
  for (const control of targets) {
2668
- const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), activeIds);
2817
+ const nextDescribedBy = mergeAriaDescribedBy(control.getAttribute('aria-describedby'), activeIds, this.ownedDescribedByIds);
2669
2818
  if (nextDescribedBy) {
2670
2819
  control.setAttribute('aria-describedby', nextDescribedBy);
2671
2820
  }
@@ -2721,10 +2870,10 @@ class FormErrorControlDirective {
2721
2870
  const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
2722
2871
  this.formControls.set(Array.from(controls));
2723
2872
  }
2724
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2725
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormErrorControlDirective, isStandalone: true, selector: "[formErrorControl], [ngxErrorControl]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorControl", "ngxErrorControl"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], ngImport: i0 }); }
2873
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2874
+ 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 }); }
2726
2875
  }
2727
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorControlDirective, decorators: [{
2876
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormErrorControlDirective, decorators: [{
2728
2877
  type: Directive,
2729
2878
  args: [{
2730
2879
  selector: '[formErrorControl], [ngxErrorControl]',
@@ -2732,50 +2881,80 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
2732
2881
  hostDirectives: [
2733
2882
  {
2734
2883
  directive: FormErrorDisplayDirective,
2735
- inputs: ['errorDisplayMode'],
2884
+ inputs: ['errorDisplayMode', 'warningDisplayMode'],
2736
2885
  },
2737
2886
  ],
2738
2887
  }]
2739
2888
  }], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
2740
2889
 
2741
2890
  /**
2742
- * Hooks into the ngModelGroup selector and triggers an asynchronous validation for a form group
2743
- * It will use a vest suite behind the scenes
2891
+ * @internal
2892
+ * Shared bridge for resolving a field path and invoking FormDirective async validation.
2893
+ * Keeps fail-open semantics for missing context/path while consolidating behavior.
2894
+ *
2895
+ * @param control - Control being validated by Angular forms.
2896
+ * @param context - Optional parent form directive context.
2897
+ * @param resolveField - Resolver that maps the control to a field path.
2898
+ * @param validationOptions - Per-control validation options.
2899
+ * @param source - Caller identifier for diagnostics.
2900
+ */
2901
+ function runAsyncValidationBridge(control, context, resolveField, validationOptions, source) {
2902
+ if (!control) {
2903
+ return of(null);
2904
+ }
2905
+ if (!context) {
2906
+ if (isDevMode()) {
2907
+ console.warn(`[ngx-vest-forms] ${source}: No FormDirective context found. Validation skipped (fail-open).`);
2908
+ }
2909
+ return of(null);
2910
+ }
2911
+ const field = resolveField(control);
2912
+ if (!field) {
2913
+ if (isDevMode()) {
2914
+ console.warn(`[ngx-vest-forms] ${source}: Could not resolve control path. Ensure the control has a valid name/path and is registered in the form tree.`);
2915
+ }
2916
+ return of(null);
2917
+ }
2918
+ const asyncValidator = context.createAsyncValidator(field, validationOptions);
2919
+ const validationResult = asyncValidator(control);
2920
+ if (validationResult instanceof Observable) {
2921
+ return validationResult;
2922
+ }
2923
+ if (validationResult instanceof Promise) {
2924
+ return from(validationResult);
2925
+ }
2926
+ return of(validationResult ?? null);
2927
+ }
2928
+
2929
+ /**
2930
+ * Hooks into `ngModelGroup`/`ngxModelGroup` and runs async group-level validation
2931
+ * through the parent `FormDirective` Vest suite bridge.
2744
2932
  */
2745
2933
  class FormModelGroupDirective {
2746
2934
  constructor() {
2935
+ /**
2936
+ * Per-group async validation options.
2937
+ *
2938
+ * Defaults to no debounce (`{ debounceTime: 0 }`).
2939
+ */
2747
2940
  this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
2748
2941
  this.formDirective = inject(FormDirective, { optional: true });
2749
2942
  }
2943
+ /**
2944
+ * Runs async validation for the current model-group control.
2945
+ *
2946
+ * Returns `null` (fail-open) when used outside an `ngxVestForm` context.
2947
+ */
2750
2948
  validate(control) {
2751
- // Null check for control
2752
- if (!control) {
2753
- return of(null);
2754
- }
2755
- // Null check for form context
2756
- const context = this.formDirective;
2757
- if (!context) {
2758
- return of(null);
2759
- }
2760
- const { ngForm } = context;
2761
- const field = getFormGroupField(ngForm.control, control);
2762
- if (!field) {
2763
- return of(null);
2764
- }
2765
- const asyncValidator = context.createAsyncValidator(field, this.validationOptions());
2766
- const validationResult = asyncValidator(control);
2767
- if (validationResult instanceof Observable) {
2768
- return validationResult;
2769
- }
2770
- else if (validationResult instanceof Promise) {
2771
- return from(validationResult);
2772
- }
2773
- else {
2774
- return of(null);
2775
- }
2949
+ return runAsyncValidationBridge(control, this.formDirective, (currentControl) => {
2950
+ const context = this.formDirective;
2951
+ if (!context)
2952
+ return '';
2953
+ return getFormGroupField(context.ngForm.control, currentControl);
2954
+ }, this.validationOptions(), 'FormModelGroupDirective');
2776
2955
  }
2777
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2778
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup],[ngxModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2956
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2957
+ 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: [
2779
2958
  {
2780
2959
  provide: NG_ASYNC_VALIDATORS,
2781
2960
  useExisting: FormModelGroupDirective,
@@ -2783,7 +2962,7 @@ class FormModelGroupDirective {
2783
2962
  },
2784
2963
  ], ngImport: i0 }); }
2785
2964
  }
2786
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelGroupDirective, decorators: [{
2965
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelGroupDirective, decorators: [{
2787
2966
  type: Directive,
2788
2967
  args: [{
2789
2968
  selector: '[ngModelGroup],[ngxModelGroup]',
@@ -2798,11 +2977,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
2798
2977
  }], propDecorators: { validationOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationOptions", required: false }] }] } });
2799
2978
 
2800
2979
  /**
2801
- * Hooks into the ngModel selector and triggers an asynchronous validation for a form model
2802
- * It will use a vest suite behind the scenes
2980
+ * Hooks into `ngModel`/`ngxModel` and runs async field-level validation
2981
+ * through the parent `FormDirective` Vest suite bridge.
2803
2982
  */
2804
2983
  class FormModelDirective {
2805
2984
  constructor() {
2985
+ /**
2986
+ * Per-control async validation options.
2987
+ *
2988
+ * Defaults to no debounce (`{ debounceTime: 0 }`).
2989
+ */
2806
2990
  this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
2807
2991
  /**
2808
2992
  * Reference to the form that needs to be validated
@@ -2813,36 +2997,21 @@ class FormModelDirective {
2813
2997
  optional: true,
2814
2998
  });
2815
2999
  }
3000
+ /**
3001
+ * Runs field-level async validation for this control.
3002
+ *
3003
+ * Returns `null` (fail-open) when used outside an `ngxVestForm` context.
3004
+ */
2816
3005
  validate(control) {
2817
- // Null check for control
2818
- if (!control) {
2819
- return of(null);
2820
- }
2821
- // Null check for form context
2822
- const context = this.formDirective;
2823
- if (!context) {
2824
- return of(null);
2825
- }
2826
- const { ngForm } = context;
2827
- const field = getFormControlField(ngForm.control, control);
2828
- if (!field) {
2829
- return of(null);
2830
- }
2831
- const asyncValidator = context.createAsyncValidator(field, this.validationOptions());
2832
- // Pass the control to the validator
2833
- const validationResult = asyncValidator(control);
2834
- if (validationResult instanceof Observable) {
2835
- return validationResult;
2836
- }
2837
- else if (validationResult instanceof Promise) {
2838
- return from(validationResult);
2839
- }
2840
- else {
2841
- return of(null);
2842
- }
3006
+ return runAsyncValidationBridge(control, this.formDirective, (currentControl) => {
3007
+ const context = this.formDirective;
3008
+ if (!context)
3009
+ return '';
3010
+ return getFormControlField(context.ngForm.control, currentControl);
3011
+ }, this.validationOptions(), 'FormModelDirective');
2843
3012
  }
2844
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2845
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormModelDirective, isStandalone: true, selector: "[ngModel],[ngxModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
3013
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3014
+ 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: [
2846
3015
  {
2847
3016
  provide: NG_ASYNC_VALIDATORS,
2848
3017
  useExisting: FormModelDirective,
@@ -2850,7 +3019,7 @@ class FormModelDirective {
2850
3019
  },
2851
3020
  ], ngImport: i0 }); }
2852
3021
  }
2853
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelDirective, decorators: [{
3022
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: FormModelDirective, decorators: [{
2854
3023
  type: Directive,
2855
3024
  args: [{
2856
3025
  selector: '[ngModel],[ngxModel]',
@@ -3087,8 +3256,8 @@ class ValidateRootFormDirective {
3087
3256
  }), take(1), takeUntilDestroyed(this.destroyRef));
3088
3257
  };
3089
3258
  }
3090
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3091
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: ValidateRootFormDirective, isStandalone: true, selector: "form[validateRootForm], form[ngxValidateRootForm]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null }, formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, validateRootForm: { classPropertyName: "validateRootForm", publicName: "validateRootForm", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootForm: { classPropertyName: "ngxValidateRootForm", publicName: "ngxValidateRootForm", isSignal: true, isRequired: false, transformFunction: null }, validateRootFormMode: { classPropertyName: "validateRootFormMode", publicName: "validateRootFormMode", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootFormMode: { classPropertyName: "ngxValidateRootFormMode", publicName: "ngxValidateRootFormMode", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
3259
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
3260
+ 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: [
3092
3261
  {
3093
3262
  provide: NG_ASYNC_VALIDATORS,
3094
3263
  useExisting: ValidateRootFormDirective,
@@ -3096,7 +3265,7 @@ class ValidateRootFormDirective {
3096
3265
  },
3097
3266
  ], ngImport: i0 }); }
3098
3267
  }
3099
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
3268
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
3100
3269
  type: Directive,
3101
3270
  args: [{
3102
3271
  selector: 'form[validateRootForm], form[ngxValidateRootForm]',
@@ -3235,6 +3404,10 @@ function createEmptyFormState() {
3235
3404
  };
3236
3405
  }
3237
3406
 
3407
+ // NOTE: `typeof ngDevMode !== 'undefined' && ngDevMode` is kept inline
3408
+ // (not extracted to a helper) because Angular's build optimizer relies on
3409
+ // this exact pattern for tree-shaking dev-only code from production bundles.
3410
+ const LOG_PREFIX = '[ngx-vest-forms] ValidationConfigBuilder';
3238
3411
  /**
3239
3412
  * Fluent builder for creating type-safe validation configurations.
3240
3413
  *
@@ -3324,7 +3497,7 @@ class ValidationConfigBuilder {
3324
3497
  if (typeof ngDevMode !== 'undefined' && ngDevMode) {
3325
3498
  const duplicates = deps.filter((d) => existing.includes(d));
3326
3499
  if (duplicates.length > 0) {
3327
- console.warn(`[ngx-vest-forms] ValidationConfigBuilder: Duplicate dependencies detected.\n` +
3500
+ console.warn(`${LOG_PREFIX}: Duplicate dependencies detected.\n` +
3328
3501
  ` Trigger: '${trigger}'\n` +
3329
3502
  ` Duplicates: ${duplicates.map((d) => `'${d}'`).join(', ')}\n` +
3330
3503
  ` These will be automatically deduplicated.`);
@@ -3392,7 +3565,7 @@ class ValidationConfigBuilder {
3392
3565
  const hasField1ToField2 = this.config[field1]?.includes(field2) ?? false;
3393
3566
  const hasField2ToField1 = this.config[field2]?.includes(field1) ?? false;
3394
3567
  if (hasField1ToField2 && hasField2ToField1) {
3395
- console.warn(`[ngx-vest-forms] ValidationConfigBuilder: Duplicate bidirectional relationship detected.\n` +
3568
+ console.warn(`${LOG_PREFIX}: Duplicate bidirectional relationship detected.\n` +
3396
3569
  ` Fields: '${field1}' ↔ '${field2}'\n` +
3397
3570
  ` This bidirectional relationship was already configured.`);
3398
3571
  }
@@ -3627,11 +3800,6 @@ function createValidationConfig() {
3627
3800
  return new ValidationConfigBuilder();
3628
3801
  }
3629
3802
 
3630
- /**
3631
- * Converts a flat array to an object with numeric keys.
3632
- * Does not recurse into nested arrays or objects.
3633
- * Uses reduce() for optimal single-pass conversion.
3634
- */
3635
3803
  function arrayToObject(array) {
3636
3804
  return array.reduce((acc, value, index) => {
3637
3805
  acc[index] = value;
@@ -3686,7 +3854,11 @@ function objectToArray(object, keys) {
3686
3854
  if (processed && typeof processed === 'object' && !Array.isArray(processed)) {
3687
3855
  const entries = Object.entries(processed);
3688
3856
  if (entries.length === 1) {
3689
- const [k, v] = entries[0];
3857
+ const firstEntry = entries[0];
3858
+ if (!firstEntry) {
3859
+ return processed;
3860
+ }
3861
+ const [k, v] = firstEntry;
3690
3862
  if (keys.includes(k) &&
3691
3863
  Array.isArray(v) &&
3692
3864
  v.every((element) => typeof element === 'object' && element !== null)) {
@@ -3785,7 +3957,7 @@ function isNumericObject(value) {
3785
3957
  * Uses `Object.entries()` for efficient field clearing without mutation.
3786
3958
  * Only processes fields that need to be cleared, preserving other field values.
3787
3959
  *
3788
- * @template T - The form model type, must extend Record<string, any>
3960
+ * @template T - The form model type, must extend Record<string, unknown>
3789
3961
  * @param currentState - The current form state object
3790
3962
  * @param conditions - Object mapping field names to boolean conditions (true = clear field)
3791
3963
  * @returns New state object with specified fields cleared (set to undefined)
@@ -3838,11 +4010,12 @@ function isNumericObject(value) {
3838
4010
  */
3839
4011
  function clearFieldsWhen(currentState, conditions) {
3840
4012
  const result = { ...currentState };
3841
- Object.entries(conditions).forEach(([fieldName, shouldClear]) => {
4013
+ for (const fieldName of Object.keys(conditions)) {
4014
+ const shouldClear = conditions[fieldName];
3842
4015
  if (shouldClear) {
3843
4016
  result[fieldName] = undefined;
3844
4017
  }
3845
- });
4018
+ }
3846
4019
  return result;
3847
4020
  }
3848
4021
  /**
@@ -3857,7 +4030,7 @@ function clearFieldsWhen(currentState, conditions) {
3857
4030
  * **Note:** Unlike `clearFieldsWhen`, this function always clears the specified fields
3858
4031
  * regardless of conditions. Use this when you want unconditional field removal.
3859
4032
  *
3860
- * @template T - The form model type, must extend Record<string, any>
4033
+ * @template T - The form model type, must extend Record<string, unknown>
3861
4034
  * @param currentState - The current form state object
3862
4035
  * @param fieldsToClear - Array of field names to clear (set to undefined)
3863
4036
  * @returns New state object with specified fields cleared
@@ -3883,9 +4056,9 @@ function clearFieldsWhen(currentState, conditions) {
3883
4056
  */
3884
4057
  function clearFields(currentState, fieldsToClear) {
3885
4058
  const result = { ...currentState };
3886
- fieldsToClear.forEach((fieldName) => {
4059
+ for (const fieldName of fieldsToClear) {
3887
4060
  result[fieldName] = undefined;
3888
- });
4061
+ }
3889
4062
  return result;
3890
4063
  }
3891
4064
  /**
@@ -3902,7 +4075,7 @@ function clearFields(currentState, fieldsToClear) {
3902
4075
  *
3903
4076
  * **Returns:** `Partial<T>` because the result may not contain all original fields.
3904
4077
  *
3905
- * @template T - The form model type, must extend Record<string, any>
4078
+ * @template T - The form model type, must extend Record<string, unknown>
3906
4079
  * @param currentState - The current form state object
3907
4080
  * @param conditions - Object mapping field names to boolean conditions (true = keep field)
3908
4081
  * @returns New state object containing only fields where condition is true
@@ -3939,12 +4112,12 @@ function clearFields(currentState, fieldsToClear) {
3939
4112
  */
3940
4113
  function keepFieldsWhen(currentState, conditions) {
3941
4114
  const result = {};
3942
- Object.entries(conditions).forEach(([fieldName, shouldKeep]) => {
4115
+ for (const fieldName of Object.keys(conditions)) {
4116
+ const shouldKeep = conditions[fieldName];
3943
4117
  if (shouldKeep && fieldName in currentState) {
3944
- result[fieldName] =
3945
- currentState[fieldName];
4118
+ result[fieldName] = currentState[fieldName];
3946
4119
  }
3947
- });
4120
+ }
3948
4121
  return result;
3949
4122
  }
3950
4123