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, ElementRef, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
2
+ import { InjectionToken, isDevMode, inject, ElementRef, DestroyRef, ChangeDetectorRef, signal, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
3
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, distinctUntilChanged, merge, switchMap, take, of, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
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 status signal to ensure recomputation on validation changes
958
- this.#statusSignal();
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(merge(
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