ngx-vest-forms 2.5.0 → 2.6.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 CHANGED
@@ -401,6 +401,7 @@ const shape: NgxDeepRequired<MyFormModel> = {
401
401
  ### Advanced Patterns
402
402
 
403
403
  - **[ValidationConfig vs Root-Form](./docs/VALIDATION-CONFIG-VS-ROOT-FORM.md)** - Cross-field dependencies and form-level rules
404
+ - **[Clear Submitted State](./docs/CLEAR-SUBMITTED-STATE.md)** - End a submit cycle without resetting values or control metadata
404
405
  - **[Field Path Types](./docs/FIELD-PATHS.md)** - Type-safe dot-notation paths for nested properties
405
406
  - **[Structure Change Detection](./docs/STRUCTURE_CHANGE_DETECTION.md)** - Handle dynamic form structure updates
406
407
  - **[Field Clearing Utilities](./docs/FIELD-CLEARING-UTILITIES.md)** - Type-safe utilities for clearing nested form values
@@ -1,6 +1,6 @@
1
1
  import * as i0 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
- import { isFormArray, isFormGroup, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, FormGroup, FormArray, NgModel, NgModelGroup, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
2
+ import { InjectionToken, isDevMode, signal, inject, ElementRef, DestroyRef, ChangeDetectorRef, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
3
+ import { isFormArray, isFormGroup, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, FormGroup, FormArray, NgModel, NgModelGroup, FormSubmittedEvent, FormResetEvent, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
4
4
  import { toSignal, outputFromObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
5
5
  import { startWith, merge, filter, map, switchMap, take, of, scan, distinctUntilChanged, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
6
6
 
@@ -896,6 +896,46 @@ function validateFormValueAgainstShape(formValue, shape, path = '') {
896
896
  }
897
897
  }
898
898
 
899
+ const formSubmittedSignals = new WeakMap();
900
+ function getFormSubmittedSignal(ngForm) {
901
+ let submitted = formSubmittedSignals.get(ngForm);
902
+ if (!submitted) {
903
+ submitted = signal(ngForm.submitted);
904
+ formSubmittedSignals.set(ngForm, submitted);
905
+ }
906
+ return submitted;
907
+ }
908
+ function setAngularFormSubmittedState(ngForm, submitted) {
909
+ // Angular 21.x's concrete NgForm stores submitted state on
910
+ // `submittedReactive`, while AbstractFormDirective-backed implementations
911
+ // expose `_submittedReactive` and may also provide a public setter on
912
+ // `submitted`. This helper is verified against Angular 21.x in this
913
+ // repository and falls back to the first writable `submitted` setter it can
914
+ // find if a future Angular version changes the concrete field names.
915
+ //
916
+ // We try the concrete/internal signal first because NgForm overrides the
917
+ // getter-only `submitted` property at runtime in this workspace. If neither
918
+ // signal exists, fall back to the first writable `submitted` setter we can
919
+ // find on the prototype chain. If no setter exists either, callers should
920
+ // still update ngx-vest-forms' shared signal so error display state remains
921
+ // reactive even though Angular's native submitted flag cannot be changed.
922
+ const signalHost = ngForm;
923
+ const angularSignal = signalHost.submittedReactive ?? signalHost._submittedReactive;
924
+ if (angularSignal) {
925
+ angularSignal.set(submitted);
926
+ return;
927
+ }
928
+ let prototype = Object.getPrototypeOf(ngForm);
929
+ while (prototype) {
930
+ const submittedDescriptor = Object.getOwnPropertyDescriptor(prototype, 'submitted');
931
+ if (submittedDescriptor?.set) {
932
+ submittedDescriptor.set.call(ngForm, submitted);
933
+ return;
934
+ }
935
+ prototype = Object.getPrototypeOf(prototype);
936
+ }
937
+ }
938
+
899
939
  /**
900
940
  * Duration (in milliseconds) to keep fields marked as "in-progress" after validation.
901
941
  * This prevents immediate re-triggering of bidirectional validations.
@@ -1364,6 +1404,52 @@ class FormDirective {
1364
1404
  this.ngForm.form.markAllAsTouched();
1365
1405
  this.#blurTick.update((v) => v + 1);
1366
1406
  }
1407
+ /**
1408
+ * Clears the current submit cycle without resetting control values or metadata.
1409
+ *
1410
+ * Unlike {@link resetForm}, this only flips the submitted gate back to `false`.
1411
+ * Touched/dirty/pristine state is preserved so consumers can end `'on-submit'`
1412
+ * error visibility without a full form reset.
1413
+ *
1414
+ * **When to use:**
1415
+ * - You use submit-gated error visibility such as `'on-submit'`
1416
+ * - A submit attempt already happened
1417
+ * - The user resolved the current submit-time errors
1418
+ * - You want future untouched fields to wait for the next submit before showing errors
1419
+ *
1420
+ * **Why this exists:**
1421
+ * `resetForm()` would also clear touched/dirty/pristine metadata, which is often
1422
+ * too disruptive for long-form, multi-form, or mixed error-display flows.
1423
+ *
1424
+ * **What it does NOT do:**
1425
+ * - Does not change field values
1426
+ * - Does not mark controls pristine or untouched
1427
+ * - Does not re-run validation
1428
+ *
1429
+ * @example
1430
+ * ```typescript
1431
+ * submitAll(): void {
1432
+ * for (const form of this.submitForms()) {
1433
+ * form.ngForm.onSubmit(new Event('submit'));
1434
+ * }
1435
+ *
1436
+ * if (this.submitForms().every((form) => form.formState().valid)) {
1437
+ * for (const form of this.submitForms()) {
1438
+ * form.clearSubmittedState();
1439
+ * }
1440
+ * }
1441
+ * }
1442
+ * ```
1443
+ *
1444
+ * @see {@link resetForm} to fully reset values and control metadata
1445
+ * @see {@link markAllAsTouched} to manually show all errors
1446
+ * @see {@link triggerFormValidation} to re-run validation after structure changes
1447
+ */
1448
+ clearSubmittedState() {
1449
+ setAngularFormSubmittedState(this.ngForm, false);
1450
+ getFormSubmittedSignal(this.ngForm).set(false);
1451
+ this.#blurTick.update((v) => v + 1);
1452
+ }
1367
1453
  /**
1368
1454
  * Finds the first invalid element in this form, scrolls it into view, and focuses it.
1369
1455
  *
@@ -2178,15 +2264,14 @@ class FormErrorDisplayDirective {
2178
2264
  #formControlState;
2179
2265
  // Optionally inject NgForm for form submission tracking
2180
2266
  #ngForm;
2181
- /**
2182
- * Internal trigger signal that updates whenever form submit or status changes.
2183
- * Used to ensure reactive tracking for the formSubmitted computed signal.
2184
- */
2185
- #formEventTrigger;
2267
+ #formSubmittedState;
2186
2268
  constructor() {
2187
2269
  this.#formControlState = inject(FormControlStateDirective);
2188
2270
  // Optionally inject NgForm for form submission tracking
2189
2271
  this.#ngForm = inject(NgForm, { optional: true });
2272
+ this.#formSubmittedState = this.#ngForm
2273
+ ? getFormSubmittedSignal(this.#ngForm)
2274
+ : signal(false);
2190
2275
  /**
2191
2276
  * Input signal for error display mode.
2192
2277
  * Works seamlessly with hostDirectives in Angular 19+.
@@ -2216,27 +2301,16 @@ class FormErrorDisplayDirective {
2216
2301
  * formSubmitted: true after the form is submitted (if NgForm is present)
2217
2302
  */
2218
2303
  this.updateOn = this.#formControlState.updateOn;
2219
- /**
2220
- * Internal trigger signal that updates whenever form submit or status changes.
2221
- * Used to ensure reactive tracking for the formSubmitted computed signal.
2222
- */
2223
- this.#formEventTrigger = this.#ngForm
2224
- ? toSignal(merge(this.#ngForm.ngSubmit, this.#ngForm.statusChanges ?? of()).pipe(startWith(null)), { initialValue: null })
2225
- : signal(null);
2226
2304
  /**
2227
2305
  * Signal that tracks NgForm.submitted state reactively.
2228
2306
  *
2229
- * Uses a trigger signal pattern for cleaner reactive tracking:
2230
- * - ngSubmit: fires when form is submitted (sets NgForm.submitted = true)
2231
- * - statusChanges: fires after resetForm() (which sets NgForm.submitted = false)
2307
+ * Map form-level submit/reset events directly to boolean state.
2232
2308
  *
2233
- * This ensures proper sync with both submit and reset operations.
2309
+ * This keeps programmatic `NgForm.onSubmit()` reactive in zoneless mode and
2310
+ * avoids depending on `NgForm.submitted`, whose getter intentionally reads an
2311
+ * internal signal with `untracked()`.
2234
2312
  */
2235
- this.formSubmitted = computed(() => {
2236
- // Trigger signal ensures this recomputes on submit/status changes
2237
- this.#formEventTrigger();
2238
- return this.#ngForm?.submitted ?? false;
2239
- }, ...(ngDevMode ? [{ debugName: "formSubmitted" }] : []));
2313
+ this.formSubmitted = this.#formSubmittedState;
2240
2314
  /**
2241
2315
  * Determines if errors should be shown based on the specified display mode
2242
2316
  * and the control's state (touched/submitted/validated).
@@ -2355,6 +2429,16 @@ class FormErrorDisplayDirective {
2355
2429
  return hasBeenValidated || isTouched || formSubmitted;
2356
2430
  }
2357
2431
  }, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : []));
2432
+ const ngForm = this.#ngForm;
2433
+ if (ngForm) {
2434
+ ngForm.form.events
2435
+ .pipe(filter((event) => event.source === ngForm.form &&
2436
+ (event instanceof FormSubmittedEvent ||
2437
+ event instanceof FormResetEvent)), map((event) => event instanceof FormSubmittedEvent), startWith(ngForm.submitted), takeUntilDestroyed())
2438
+ .subscribe((submitted) => {
2439
+ this.#formSubmittedState.set(submitted);
2440
+ });
2441
+ }
2358
2442
  // Warn about problematic combinations of updateOn and errorDisplayMode
2359
2443
  effect(() => {
2360
2444
  const mode = this.errorDisplayMode();