ngx-vest-forms 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, isDevMode, inject, DestroyRef, ChangeDetectorRef, signal, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender,
|
|
2
|
+
import { InjectionToken, isDevMode, inject, ElementRef, DestroyRef, ChangeDetectorRef, signal, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
|
|
3
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
|
-
import { startWith, filter, map,
|
|
5
|
+
import { startWith, merge, filter, map, switchMap, take, of, scan, distinctUntilChanged, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @deprecated Use NGX_ERROR_DISPLAY_MODE_TOKEN instead
|
|
@@ -443,6 +443,81 @@ function stringifyFieldPath(path) {
|
|
|
443
443
|
return result;
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
const DEFAULT_INVALID_SELECTOR = [
|
|
447
|
+
'.ngx-control-wrapper--invalid',
|
|
448
|
+
'.ngx-form-group-wrapper--invalid',
|
|
449
|
+
'input[aria-invalid="true"]',
|
|
450
|
+
'textarea[aria-invalid="true"]',
|
|
451
|
+
'select[aria-invalid="true"]',
|
|
452
|
+
].join(', ');
|
|
453
|
+
const DEFAULT_FOCUS_SELECTOR = [
|
|
454
|
+
'input:not([type="hidden"]):not([disabled])',
|
|
455
|
+
'textarea:not([disabled])',
|
|
456
|
+
'select:not([disabled])',
|
|
457
|
+
'button:not([disabled])',
|
|
458
|
+
'a[href]',
|
|
459
|
+
'[tabindex]:not([tabindex="-1"]):not([disabled])',
|
|
460
|
+
].join(', ');
|
|
461
|
+
/**
|
|
462
|
+
* Focus candidates that are already invalid.
|
|
463
|
+
* This ensures invalid group wrappers focus a failing control before any valid sibling.
|
|
464
|
+
*/
|
|
465
|
+
const INVALID_FOCUS_PREFERRED_SELECTOR = [
|
|
466
|
+
'input[aria-invalid="true"]:not([type="hidden"]):not([disabled])',
|
|
467
|
+
'textarea[aria-invalid="true"]:not([disabled])',
|
|
468
|
+
'select[aria-invalid="true"]:not([disabled])',
|
|
469
|
+
'[aria-invalid="true"][tabindex]:not([tabindex="-1"]):not([disabled])',
|
|
470
|
+
].join(', ');
|
|
471
|
+
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
|
|
472
|
+
function prefersReducedMotion() {
|
|
473
|
+
return (typeof globalThis.matchMedia === 'function' &&
|
|
474
|
+
globalThis.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches);
|
|
475
|
+
}
|
|
476
|
+
function resolveFirstInvalidScrollBehavior(behavior) {
|
|
477
|
+
if (behavior !== undefined) {
|
|
478
|
+
return behavior;
|
|
479
|
+
}
|
|
480
|
+
return prefersReducedMotion() ? 'auto' : 'smooth';
|
|
481
|
+
}
|
|
482
|
+
function resolveFirstInvalidElement(root, invalidSelector) {
|
|
483
|
+
try {
|
|
484
|
+
const firstInvalid = root.querySelector(invalidSelector);
|
|
485
|
+
return firstInvalid instanceof HTMLElement ? firstInvalid : null;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function openCollapsedDetailsAncestors(root, element) {
|
|
492
|
+
let current = element;
|
|
493
|
+
while (current !== root) {
|
|
494
|
+
const parentElement = current.parentElement;
|
|
495
|
+
if (!parentElement) {
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
if (parentElement instanceof HTMLDetailsElement) {
|
|
499
|
+
parentElement.open = true;
|
|
500
|
+
}
|
|
501
|
+
current = parentElement;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function resolveFirstInvalidFocusTarget(firstInvalid, focusSelector) {
|
|
505
|
+
const preferredInvalidTarget = firstInvalid.querySelector(INVALID_FOCUS_PREFERRED_SELECTOR);
|
|
506
|
+
if (preferredInvalidTarget instanceof HTMLElement) {
|
|
507
|
+
return preferredInvalidTarget;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
if (firstInvalid.matches(focusSelector)) {
|
|
511
|
+
return firstInvalid;
|
|
512
|
+
}
|
|
513
|
+
const fallbackTarget = firstInvalid.querySelector(focusSelector);
|
|
514
|
+
return fallbackTarget instanceof HTMLElement ? fallbackTarget : null;
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
446
521
|
const ROOT_FORM = 'rootForm';
|
|
447
522
|
|
|
448
523
|
const ERROR_MESSAGES_KEY = 'errors';
|
|
@@ -887,8 +962,14 @@ class FormDirective {
|
|
|
887
962
|
* even when the form's aggregate touched flag is already true.
|
|
888
963
|
*/
|
|
889
964
|
#blurTick;
|
|
965
|
+
/**
|
|
966
|
+
* Counter signal tied to validation feedback updates so `formState()` can
|
|
967
|
+
* recompute whenever the underlying error set changes.
|
|
968
|
+
*/
|
|
969
|
+
#validationFeedbackTick;
|
|
890
970
|
constructor() {
|
|
891
971
|
this.ngForm = inject(NgForm, { self: true });
|
|
972
|
+
this.elementRef = inject((ElementRef));
|
|
892
973
|
this.destroyRef = inject(DestroyRef);
|
|
893
974
|
this.cdr = inject(ChangeDetectorRef);
|
|
894
975
|
this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
|
|
@@ -954,8 +1035,9 @@ class FormDirective {
|
|
|
954
1035
|
* when form status changes but actual values/errors remain the same.
|
|
955
1036
|
*/
|
|
956
1037
|
this.formState = computed(() => {
|
|
957
|
-
// Tie to
|
|
958
|
-
|
|
1038
|
+
// Tie to validation feedback instead of aggregate status so errors update
|
|
1039
|
+
// even when the root form remains INVALID -> INVALID.
|
|
1040
|
+
this.#validationFeedbackTick();
|
|
959
1041
|
return {
|
|
960
1042
|
valid: this.ngForm.form.valid,
|
|
961
1043
|
errors: getAllFormErrors(this.ngForm.form),
|
|
@@ -1007,6 +1089,21 @@ class FormDirective {
|
|
|
1007
1089
|
* @param v
|
|
1008
1090
|
*/
|
|
1009
1091
|
this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : []));
|
|
1092
|
+
/**
|
|
1093
|
+
* Emits whenever validation feedback may have changed, even if the aggregate
|
|
1094
|
+
* root form status string stays the same.
|
|
1095
|
+
*/
|
|
1096
|
+
this.validationFeedback$ = merge(this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING')), this.ngForm.ngSubmit.pipe(switchMap(() => {
|
|
1097
|
+
if (this.ngForm.form.status === 'PENDING') {
|
|
1098
|
+
return this.ngForm.form.statusChanges.pipe(filter((status) => status !== 'PENDING'), take(1));
|
|
1099
|
+
}
|
|
1100
|
+
return of(this.ngForm.form.status);
|
|
1101
|
+
})));
|
|
1102
|
+
/**
|
|
1103
|
+
* Counter signal tied to validation feedback updates so `formState()` can
|
|
1104
|
+
* recompute whenever the underlying error set changes.
|
|
1105
|
+
*/
|
|
1106
|
+
this.#validationFeedbackTick = toSignal(this.validationFeedback$.pipe(scan((count) => count + 1, 0), startWith(0)), { initialValue: 0 });
|
|
1010
1107
|
this.pending$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v === 'PENDING'), distinctUntilChanged());
|
|
1011
1108
|
/**
|
|
1012
1109
|
* Emits every time the form status changes in a state
|
|
@@ -1033,18 +1130,7 @@ class FormDirective {
|
|
|
1033
1130
|
*
|
|
1034
1131
|
* Cleanup is handled automatically by the directive when it's destroyed.
|
|
1035
1132
|
*/
|
|
1036
|
-
this.errorsChange = outputFromObservable(
|
|
1037
|
-
// Status change events (non-PENDING) - emit immediately
|
|
1038
|
-
this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING')),
|
|
1039
|
-
// Submit events - wait for async validation to complete before emitting
|
|
1040
|
-
this.ngForm.ngSubmit.pipe(switchMap(() => {
|
|
1041
|
-
// If form is PENDING (async validation in progress), wait for it to complete
|
|
1042
|
-
if (this.ngForm.form.status === 'PENDING') {
|
|
1043
|
-
return this.ngForm.form.statusChanges.pipe(filter((status) => status !== 'PENDING'), take(1));
|
|
1044
|
-
}
|
|
1045
|
-
// Form not pending, emit immediately
|
|
1046
|
-
return of(this.ngForm.form.status);
|
|
1047
|
-
}))).pipe(map(() => getAllFormErrors(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
|
|
1133
|
+
this.errorsChange = outputFromObservable(this.validationFeedback$.pipe(map(() => getAllFormErrors(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
|
|
1048
1134
|
/**
|
|
1049
1135
|
* Triggered as soon as the form becomes dirty
|
|
1050
1136
|
*
|
|
@@ -1278,6 +1364,46 @@ class FormDirective {
|
|
|
1278
1364
|
this.ngForm.form.markAllAsTouched();
|
|
1279
1365
|
this.#blurTick.update((v) => v + 1);
|
|
1280
1366
|
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Finds the first invalid element in this form, scrolls it into view, and focuses it.
|
|
1369
|
+
*
|
|
1370
|
+
* Useful in custom submit flows where `markAllAsTouched()` is triggered externally
|
|
1371
|
+
* and the app then wants to guide keyboard and assistive-technology users to the
|
|
1372
|
+
* first failing field.
|
|
1373
|
+
*
|
|
1374
|
+
* @returns The focused element when a focusable target exists, otherwise the first
|
|
1375
|
+
* matched invalid element. Returns `null` when no invalid element is found.
|
|
1376
|
+
*/
|
|
1377
|
+
focusFirstInvalidControl(options = {}) {
|
|
1378
|
+
const { block = 'center', inline = 'nearest', focus = true, preventScrollOnFocus = true, openCollapsedParents = true, invalidSelector = DEFAULT_INVALID_SELECTOR, focusSelector = DEFAULT_FOCUS_SELECTOR, } = options;
|
|
1379
|
+
const behavior = resolveFirstInvalidScrollBehavior(options.behavior);
|
|
1380
|
+
const root = this.elementRef.nativeElement;
|
|
1381
|
+
const firstInvalid = resolveFirstInvalidElement(root, invalidSelector);
|
|
1382
|
+
if (!firstInvalid) {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
if (openCollapsedParents) {
|
|
1386
|
+
openCollapsedDetailsAncestors(root, firstInvalid);
|
|
1387
|
+
}
|
|
1388
|
+
const focusTarget = resolveFirstInvalidFocusTarget(firstInvalid, focusSelector);
|
|
1389
|
+
const scrollTarget = focusTarget ?? firstInvalid;
|
|
1390
|
+
scrollTarget.scrollIntoView({ behavior, block, inline });
|
|
1391
|
+
if (focus && focusTarget) {
|
|
1392
|
+
focusTarget.focus({ preventScroll: preventScrollOnFocus });
|
|
1393
|
+
}
|
|
1394
|
+
return focusTarget ?? firstInvalid;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Finds and scrolls the first invalid element into view without moving focus.
|
|
1398
|
+
*
|
|
1399
|
+
* @returns The resolved element, or `null` when no invalid element is found.
|
|
1400
|
+
*/
|
|
1401
|
+
scrollToFirstInvalidControl(options = {}) {
|
|
1402
|
+
return this.focusFirstInvalidControl({
|
|
1403
|
+
...options,
|
|
1404
|
+
focus: false,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1281
1407
|
/**
|
|
1282
1408
|
* Host handler: called whenever any descendant field loses focus.
|
|
1283
1409
|
* Used to make touched-path tracking react immediately on blur/tab.
|
|
@@ -4129,5 +4255,5 @@ function keepFieldsWhen(currentState, conditions) {
|
|
|
4129
4255
|
* Generated bundle index. Do not edit.
|
|
4130
4256
|
*/
|
|
4131
4257
|
|
|
4132
|
-
export { ControlWrapperComponent, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NGX_WARNING_DISPLAY_MODE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeValuesAndRawValues, objectToArray, parseFieldPath, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
|
|
4258
|
+
export { ControlWrapperComponent, DEFAULT_FOCUS_SELECTOR, DEFAULT_INVALID_SELECTOR, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NGX_WARNING_DISPLAY_MODE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeAriaDescribedBy, mergeValuesAndRawValues, objectToArray, parseAriaIdTokens, parseFieldPath, resolveAssociationTargets, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
|
|
4133
4259
|
//# sourceMappingURL=ngx-vest-forms.mjs.map
|