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.
- package/README.md +5 -0
- package/fesm2022/ngx-vest-forms.mjs +430 -257
- package/fesm2022/ngx-vest-forms.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-vest-forms.d.ts +94 -61
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
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
|
|
480
|
+
while (current?.parent) {
|
|
464
481
|
const parent = current.parent;
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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] =
|
|
584
|
+
target[key] = sourceValue;
|
|
560
585
|
}
|
|
561
|
-
else if (
|
|
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(
|
|
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
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
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
|
|
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
|
|
621
|
-
if (
|
|
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
|
-
|
|
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.
|
|
652
|
-
|
|
653
|
-
|
|
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
|
|
662
|
-
for (const key of
|
|
663
|
-
const
|
|
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(
|
|
718
|
+
Number.isNaN(numericKey) ? key : numericKey,
|
|
667
719
|
];
|
|
668
|
-
|
|
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
|
|
676
|
-
for (const key of
|
|
677
|
-
const
|
|
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(
|
|
730
|
+
Number.isNaN(numericKey) ? key : numericKey,
|
|
681
731
|
];
|
|
682
|
-
|
|
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.
|
|
689
|
-
|
|
690
|
-
if (
|
|
691
|
-
errors[pathString] =
|
|
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
|
-
|
|
695
|
-
|
|
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:
|
|
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
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
1530
|
-
|
|
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.
|
|
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,
|
|
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
|
-
// -
|
|
1734
|
-
// - ValidationConfig-triggered validations
|
|
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:
|
|
1804
|
-
?
|
|
1805
|
-
:
|
|
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.
|
|
1858
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.
|
|
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.
|
|
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.
|
|
2090
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
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.
|
|
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
|
|
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 >
|
|
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
|
|
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 =
|
|
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.
|
|
2497
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.
|
|
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.
|
|
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.
|
|
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.
|
|
2560
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.
|
|
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.
|
|
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]
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
2725
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
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.
|
|
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
|
-
*
|
|
2743
|
-
*
|
|
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
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
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.
|
|
2778
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
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.
|
|
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
|
|
2802
|
-
*
|
|
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
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
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.
|
|
2845
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
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.
|
|
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.
|
|
3091
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|