ngx-vest-forms
Version:
Opinionated template-driven forms library for Angular with Vest.js integration
1,282 lines (1,269 loc) • 235 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, isDevMode, signal, inject, ElementRef, DestroyRef, ChangeDetectorRef, linkedSignal, computed, input, output, effect, untracked, Directive, contentChild, Injector, afterNextRender, afterEveryRender, isSignal, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
import { isFormArray, isFormGroup, NgForm, ValueChangeEvent, StatusChangeEvent, PristineChangeEvent, FormGroup, FormArray, NgModel, NgModelGroup, FormSubmittedEvent, FormResetEvent, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
import { toSignal, outputFromObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { filter, scan, startWith, merge, map, switchMap, take, of, distinctUntilChanged, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
/**
* @deprecated Use NGX_ERROR_DISPLAY_MODE_TOKEN instead
*/
const SC_ERROR_DISPLAY_MODE_TOKEN = new InjectionToken('SC_ERROR_DISPLAY_MODE_TOKEN', {
providedIn: 'root',
factory: () => 'on-blur-or-submit',
});
/**
* Injection token for configuring the default error display mode.
* Values:
* - 'on-blur': Show errors after field is touched/blurred
* - 'on-submit': Show errors after form submission
* - 'on-blur-or-submit': Show errors after blur or form submission (default)
* - 'on-dirty': Show errors as soon as the field value changes
* - 'always': Show errors immediately, even on pristine fields
*/
const NGX_ERROR_DISPLAY_MODE_TOKEN = new InjectionToken('NGX_ERROR_DISPLAY_MODE_TOKEN', {
providedIn: 'root',
factory: () => 'on-blur-or-submit',
});
/**
* Injection token for configuring the default warning display mode.
* Values:
* - 'on-touch': Show warnings after field is touched/blurred
* - 'on-validated-or-touch': Show warnings after validation runs or field is touched (default)
* - 'on-dirty': Show warnings as soon as the field value changes
* - 'always': Show warnings immediately, even on pristine fields
*/
const NGX_WARNING_DISPLAY_MODE_TOKEN = new InjectionToken('NGX_WARNING_DISPLAY_MODE_TOKEN', {
providedIn: 'root',
factory: () => 'on-validated-or-touch',
});
const NGX_VEST_FORMS_ERRORS = {
EXTRA_PROPERTY: {
code: 'NGX-001',
message: (path) => `Shape mismatch: Property '${path}' is present in the form value but not defined in the form shape.`,
},
TYPE_MISMATCH: {
code: 'NGX-002',
message: (path, expected, actual) => `Type mismatch at '${path}': Expected '${expected}' but got '${actual}'.`,
},
CONTROL_NOT_FOUND: {
code: 'NGX-003',
message: (path) => `Control not found: Could not find form control at path '${path}'. Check your [ngModel] name attributes.`,
},
};
function logWarning(error, ...args) {
console.warn(`[${error.code}] ${error.message(...args)}\nCheck your [formShape] input and the initial [formValue].`);
}
/**
* Injection token for configurable validation config debounce timing.
*
* This token allows you to configure the debounce time for validation config
* dependencies at the application, route, or component level.
*
* @example
* ```typescript
* /// Global configuration
* export const appConfig: ApplicationConfig = {
* providers: [
* {
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
* useValue: 200
* }
* ]
* };
*
* /// Per-route configuration
* {
* path: 'checkout',
* component: CheckoutComponent,
* providers: [
* {
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
* useValue: 50
* }
* ]
* }
*
* /// Per-component override
* @Component({
* providers: [
* {
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
* useValue: 0 // for testing
* }
* ]
* })
* export class TestFormComponent {}
* ```
*
* @default 100ms - Maintains backward compatibility with existing behavior
*/
const NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN = new InjectionToken('NgxValidationConfigDebounceTime', {
providedIn: 'root',
factory: () => 100,
});
/**
* Type guard to check if a value is a primitive type.
*
* @param value - The value to check
* @returns true if the value is a primitive (string, number, boolean, null, undefined, symbol, bigint)
*/
function isPrimitive$1(value) {
return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
}
/**
* Records that we've started comparing the pair (a, b). Re-entering the same
* pair during recursion (cycle, or repeated DAG path) short-circuits to `true`
* — correct because if the prior descent had returned `false`, the outer call
* would already have short-circuited before we reach the second visit.
*/
function rememberPair(seen, a, b) {
let targets = seen.get(a);
if (!targets) {
targets = new WeakSet();
seen.set(a, targets);
}
targets.add(b);
}
/**
* @internal
* Internal utility for shallow equality checks.
*
* **Not intended for external use.** This function is used internally by the library
* for performance-critical operations. Consider using your own comparison logic or
* a library like lodash if you need shallow equality checks in your application.
*
* Optimized shallow equality check for objects.
*
* **Why this custom implementation is preferred:**
* - **Performance**: Direct property comparison is significantly faster than JSON.stringify
* - **Type Safety**: Handles null/undefined values correctly without serialization issues
* - **Accuracy**: Doesn't suffer from JSON.stringify limitations (undefined values, functions, symbols)
* - **Memory Efficient**: No temporary string creation or object serialization overhead
*
* **Use Cases:**
* - Form value change detection where only top-level properties matter
* - Quick object comparison in performance-critical code paths
* - Validation triggers where deep comparison is unnecessary
*
* **Performance Comparison:**
* ```typescript
* /// ❌ Slow: JSON.stringify approach
* JSON.stringify(obj1) === JSON.stringify(obj2)
*
* /// ✅ Fast: Direct property comparison
* shallowEqual(obj1, obj2)
* ```
*
* @param obj1 - First object to compare
* @param obj2 - Second object to compare
* @returns true if objects are shallowly equal (same keys and same values by reference)
*/
function shallowEqual(obj1, obj2) {
if (obj1 === obj2) {
return true;
}
if (obj1 == null || obj2 == null) {
return obj1 === obj2;
}
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
return obj1 === obj2;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
if (!Object.hasOwn(obj2, key) ||
obj1[key] !==
obj2[key]) {
return false;
}
}
return true;
}
/**
* @internal
* Internal utility for deep equality checks.
*
* **Not intended for external use.** This function is used internally by the library
* for form value comparison and change detection. Consider using your own comparison
* logic or a library like lodash if you need deep equality checks in your application.
*
* Fast deep equality check optimized for form values and Angular applications.
*
* **Why this custom implementation is preferred over alternatives:**
*
* **vs JSON.stringify():**
* - **10-100x faster**: Direct comparison without string serialization overhead
* - **Accurate**: Handles Date objects, RegExp, undefined values, and functions correctly
* - **Memory efficient**: No temporary string creation or garbage collection pressure
* - **Preserves semantics**: Maintains type information during comparison
*
* **vs structuredClone():**
* - **Wrong purpose**: structuredClone creates copies, not comparisons
* - **Performance**: Would require cloning both objects just to compare them
* - **Memory waste**: Creates unnecessary deep copies, doubling memory usage
* - **Still incomplete**: Even after cloning, you'd still need a comparison function
*
* **vs External libraries (lodash.isEqual, etc.):**
* - **Bundle size**: Zero dependencies, smaller application bundles
* - **Form-specific**: Optimized for common Angular form data patterns
* - **Type safety**: Full TypeScript integration with strict typing
* - **Performance**: Tailored algorithms for form value comparison use cases
*
* **Supported Data Types:**
* - Primitives (string, number, boolean, null, undefined, symbol, bigint)
* - Arrays (with recursive deep comparison)
* - Plain objects (with recursive deep comparison)
* - Date objects (by timestamp comparison)
* - RegExp objects (by source and flags comparison)
* - Set objects (reference equality only)
* - Map objects (reference equality only)
* - Functions (reference equality only — distinct function instances are never equal,
* even if their source code is identical)
*
* **Safety Features:**
* - **Circular reference handling**: Tracks visited object pairs with `WeakMap<object, WeakSet<object>>`
* - **Type coercion prevention**: Strict type checking before comparison
* - **Null safety**: Proper handling of null and undefined values
*
* **Performance Characteristics:**
* ```typescript
* /// Performance comparison on typical form objects:
* /// JSON.stringify: ~100ms for complex nested forms
* /// fastDeepEqual: ~1-5ms for the same objects
* ///
* /// Memory usage:
* /// JSON.stringify: Creates temporary strings (high GC pressure)
* /// fastDeepEqual: One small WeakMap of visited object pairs is allocated lazily on
* /// first nested-container descent; primitive-only comparisons allocate nothing.
* ```
*
* **Typical Usage in Forms:**
* ```typescript
* /// Detect when form values actually change
* distinctUntilChanged(fastDeepEqual)
*
* /// Prevent unnecessary re-renders
* if (!fastDeepEqual(oldFormValue, newFormValue)) {
* updateUI();
* }
* ```
*
* @param obj1 - First object to compare
* @param obj2 - Second object to compare
*
* Cyclic arrays and plain objects are compared structurally by tracking visited object
* pairs. Distinct cyclic graphs with the same structure compare equal. `Date` and
* `RegExp` values compare structurally. `Map` and `Set` values compare by reference
* only, so distinct instances are considered different even if their contents match.
*
* @returns true if objects are deeply equal by value
*/
function fastDeepEqual(obj1, obj2) {
return fastDeepEqualInternal(obj1, obj2, undefined);
}
function fastDeepEqualInternal(obj1, obj2, seen) {
// Object.is gives correct semantics for NaN and ±0 — important for numeric
// form values where `fastDeepEqual(NaN, NaN)` should be true.
if (Object.is(obj1, obj2)) {
return true;
}
if (obj1 == null || obj2 == null) {
return false;
}
if (typeof obj1 !== typeof obj2) {
return false;
}
// Functions use reference-only equality. Object.is at the top already returned
// true for identical references, so reaching here means the references differ.
if (typeof obj1 === 'function') {
return false;
}
if (isPrimitive$1(obj1) || isPrimitive$1(obj2)) {
return false;
}
// Handle Date / RegExp first — they have value semantics and can't contain cycles,
// so they don't need pair tracking.
if (obj1 instanceof Date || obj2 instanceof Date) {
return (obj1 instanceof Date &&
obj2 instanceof Date &&
obj1.getTime() === obj2.getTime());
}
if (obj1 instanceof RegExp || obj2 instanceof RegExp) {
return (obj1 instanceof RegExp &&
obj2 instanceof RegExp &&
obj1.source === obj2.source &&
obj1.flags === obj2.flags);
}
// Intentional contract: distinct Set / Map instances compare by reference only.
if (obj1 instanceof Set || obj2 instanceof Set) {
return false;
}
if (obj1 instanceof Map || obj2 instanceof Map) {
return false;
}
if (Array.isArray(obj1) !== Array.isArray(obj2)) {
return false;
}
// Cycle / repeated-pair guard for traversable containers.
// Allocates lazily on first nested-object descent.
const a = obj1;
const b = obj2;
if (seen?.get(a)?.has(b)) {
return true;
}
seen ??= new WeakMap();
rememberPair(seen, a, b);
if (Array.isArray(obj1)) {
const arr2 = obj2;
if (obj1.length !== arr2.length) {
return false;
}
for (let i = 0; i < obj1.length; i++) {
if (!fastDeepEqualInternal(obj1[i], arr2[i], seen)) {
return false;
}
}
return true;
}
// Plain objects
const keys1 = Object.keys(a);
const o2 = b;
if (keys1.length !== Object.keys(o2).length) {
return false;
}
for (const key of keys1) {
if (!Object.hasOwn(o2, key)) {
return false;
}
if (!fastDeepEqualInternal(a[key], o2[key], seen)) {
return false;
}
}
return true;
}
/**
* Injection token for the deep-equality function used internally by
* {@link FormDirective} for change detection — `formValueChange`
* `distinctUntilChanged`, the form↔model two-way sync effect, and the
* `formState` signal's structural comparator.
*
* The default factory returns {@link fastDeepEqual}. Override this token to
* plug in a smaller or differently-tuned comparator (e.g. `dequal/lite`,
* `lodash.isEqual`) without forking the directive.
*
* @example Bring-your-own equality at the application level
* ```ts
* import { dequal } from 'dequal/lite';
* import { NGX_EQUALITY_FN } from 'ngx-vest-forms';
*
* export const appConfig: ApplicationConfig = {
* providers: [
* { provide: NGX_EQUALITY_FN, useValue: dequal },
* ],
* };
* ```
*
* @example Per-component override (e.g. for tests)
* ```ts
* @Component({
* providers: [
* { provide: NGX_EQUALITY_FN, useValue: (a, b) => a === b },
* ],
* })
* export class TestFormComponent {}
* ```
*
* @default {@link fastDeepEqual}
*/
const NGX_EQUALITY_FN = new InjectionToken('NgxEqualityFn', {
providedIn: 'root',
factory: () => fastDeepEqual,
});
/**
* Schedules a callback to run after a delay, automatically cancelling it if the
* provided `DestroyRef` fires before the timer expires.
*
* @param callback - Function to invoke after the delay.
* @param delayMs - Delay in milliseconds (passed to `setTimeout`).
* @param destroyRef - Angular `DestroyRef` to register auto-cancellation against.
* @returns A cancel function; calling it before the timer fires prevents the
* callback from running and unregisters the destroy listener.
*/
function scheduleTimeout(callback, delayMs, destroyRef) {
let cancelled = false;
let unregisterCalled = false;
// eslint-disable-next-line prefer-const -- assigned after onDestroy returns
let unregisterDestroy;
// Idempotent unregister wrapper so all paths (timer-fires, explicit-cancel,
// and onDestroy) can call it safely without double-removal.
const safeUnregister = () => {
if (unregisterCalled)
return;
unregisterCalled = true;
unregisterDestroy?.();
};
const handle = setTimeout(() => {
safeUnregister();
if (!cancelled) {
callback();
}
}, delayMs);
unregisterDestroy = destroyRef.onDestroy(() => {
cancelled = true;
clearTimeout(handle);
safeUnregister();
});
return () => {
if (!cancelled) {
cancelled = true;
clearTimeout(handle);
safeUnregister();
}
};
}
/**
* Schedules a microtask callback, automatically suppressing it if the provided
* `DestroyRef` fires before the microtask runs.
*
* Since `queueMicrotask` has no native cancellation API, this relies on a
* destroyed flag that is set inside the registered `onDestroy` hook.
*
* @param callback - Function to invoke in the next microtask checkpoint.
* @param destroyRef - Angular `DestroyRef` to register auto-cancellation against.
* @returns A cancel function; calling it before the microtask runs prevents the
* callback from executing and unregisters the destroy listener.
*/
function scheduleMicrotask(callback, destroyRef) {
let cancelled = false;
let unregisterCalled = false;
// Register the onDestroy listener before queuing the microtask so that
// `unregisterDestroy` is always defined when the microtask fires.
const unregisterDestroy = destroyRef.onDestroy(() => {
cancelled = true;
safeUnregister();
});
// Idempotent unregister wrapper so all paths (microtask-fires, explicit-cancel,
// and onDestroy) can call it safely without double-removal.
function safeUnregister() {
if (unregisterCalled)
return;
unregisterCalled = true;
unregisterDestroy();
}
queueMicrotask(() => {
// Always clean up the destroy listener when the microtask fires,
// regardless of whether the callback is suppressed.
safeUnregister();
if (!cancelled) {
callback();
}
});
return () => {
if (!cancelled) {
cancelled = true;
safeUnregister();
}
};
}
/**
* Utilities for working with field paths in dot/bracket notation and Standard Schema path arrays.
*
* These utilities help convert between:
* - **Dot/bracket notation**: `'addresses[0].street'` (used in Angular forms and Vest)
* - **Standard Schema paths**: `['addresses', 0, 'street']` (used in schema validation)
*
* @example
* ```typescript
* // Parse string path to array
* parseFieldPath('user.addresses[0].street')
* // Returns: ['user', 'addresses', 0, 'street']
*
* // Stringify array path to string
* stringifyFieldPath(['user', 'addresses', 0, 'street'])
* // Returns: 'user.addresses[0].street'
* ```
*/
const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
const LOG_PREFIX$1 = '[ngx-vest-forms] field-path.utils';
/**
* @internal
* Returns whether a path segment is unsafe for object writes/merges.
*
* Unsafe segments are blocked to prevent prototype pollution vectors.
*/
function isUnsafePathSegment(segment) {
return typeof segment === 'string' && UNSAFE_PATH_SEGMENTS.has(segment);
}
/**
* @internal
* Internal utility for parsing field path strings.
*
* **Not intended for external use.** While this function can parse field paths,
* it's primarily used internally. Most users won't need to parse field paths manually.
* If you do need this functionality, consider using your own implementation tailored
* to your specific needs.
*
* Converts a dot/bracket notation path (e.g. `'addresses[0].street'`)
* to a Standard Schema path array (e.g. `['addresses', 0, 'street']`).
*
* **Use cases:**
* - Converting Angular form paths to schema-compatible arrays
* - Parsing Vest field names for array access
* - Processing validation error paths from different sources
*
* **Supported formats:**
* - Dot notation: `'user.name'` → `['user', 'name']`
* - Bracket notation: `'addresses[0]'` → `['addresses', 0]`
* - Mixed: `'user.addresses[0].street'` → `['user', 'addresses', 0, 'street']`
*
* @param path - The dot/bracket notation path string
* @returns Array of path segments (strings for properties, numbers for array indices)
*
* @example
* ```typescript
* parseFieldPath('email') // ['email']
* parseFieldPath('user.profile.name') // ['user', 'profile', 'name']
* parseFieldPath('items[0]') // ['items', 0]
* parseFieldPath('users[0].addresses[1].street')
* // ['users', 0, 'addresses', 1, 'street']
* ```
*/
function parseFieldPath(path) {
if (!path)
return [];
// Normalize bracket notation to dot notation first so malformed inputs like
// 'a.[0]' (which becomes 'a..0') are caught alongside 'a..b', '.a', 'a.', '.'.
const startsWithBracket = path.startsWith('[');
const segments = path
.replaceAll(/\[(\d+)\]/g, '.$1')
.split('.');
// Empty segments after normalization signal a malformed path. The single
// legitimate case is a leading empty produced by a path that originally
// started with '[' (e.g. '[0].x' → '.0.x' → ['', '0', 'x']).
for (let i = 0; i < segments.length; i++) {
if (segments[i] === '' && !(i === 0 && startsWithBracket)) {
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
console.warn(`${LOG_PREFIX$1}: Invalid field path '${path}'. Leading dots, trailing dots, consecutive dots, and '.[' separators are not allowed.`);
}
return [];
}
}
return segments
.filter((part) => part !== '')
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
}
/**
* Converts a Standard Schema path array (e.g. `['addresses', 0, 'street']`)
* to a dot/bracket notation string (e.g. `'addresses[0].street'`).
*
* **Use cases:**
* - Converting schema validation paths to Angular form field names
* - Generating Vest field names from path arrays
* - Creating human-readable field identifiers
*
* **Format rules:**
* - String segments joined with dots: `['user', 'name']` → `'user.name'`
* - Number segments use brackets: `['items', 0]` → `'items[0]'`
* - Mixed paths combine both: `['users', 0, 'email']` → `'users[0].email'`
*
* @param path - Array of path segments (strings for properties, numbers for indices)
* @returns Dot/bracket notation path string
*
* @example
* ```typescript
* stringifyFieldPath(['email']) // 'email'
* stringifyFieldPath(['user', 'profile', 'name']) // 'user.profile.name'
* stringifyFieldPath(['items', 0]) // 'items[0]'
* stringifyFieldPath(['users', 0, 'addresses', 1, 'street'])
* // 'users[0].addresses[1].street'
* ```
*/
function stringifyFieldPath(path) {
if (!path || path.length === 0)
return '';
let result = '';
for (let i = 0; i < path.length; i++) {
const segment = path[i];
if (typeof segment === 'number') {
// Array index - add as bracket notation
result += `[${segment}]`;
}
else {
// Property name - add with dot prefix if not first segment
if (i > 0)
result += '.';
result += segment;
}
}
return result;
}
const DEFAULT_INVALID_SELECTOR = [
'.ngx-control-wrapper--invalid',
'.ngx-form-group-wrapper--invalid',
'input[aria-invalid="true"]',
'textarea[aria-invalid="true"]',
'select[aria-invalid="true"]',
].join(', ');
const DEFAULT_FOCUS_SELECTOR = [
'input:not([type="hidden"]):not([disabled])',
'textarea:not([disabled])',
'select:not([disabled])',
'button:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"]):not([disabled])',
].join(', ');
/**
* Focus candidates that are already invalid.
* This ensures invalid group wrappers focus a failing control before any valid sibling.
*/
const INVALID_FOCUS_PREFERRED_SELECTOR = [
'input[aria-invalid="true"]:not([type="hidden"]):not([disabled])',
'textarea[aria-invalid="true"]:not([disabled])',
'select[aria-invalid="true"]:not([disabled])',
'[aria-invalid="true"][tabindex]:not([tabindex="-1"]):not([disabled])',
].join(', ');
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
function prefersReducedMotion() {
return (typeof globalThis.matchMedia === 'function' &&
globalThis.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches);
}
function resolveFirstInvalidScrollBehavior(behavior) {
if (behavior !== undefined) {
return behavior;
}
return prefersReducedMotion() ? 'auto' : 'smooth';
}
function resolveFirstInvalidElement(root, invalidSelector) {
try {
const firstInvalid = root.querySelector(invalidSelector);
return firstInvalid instanceof HTMLElement ? firstInvalid : null;
}
catch {
return null;
}
}
function openCollapsedDetailsAncestors(root, element) {
let current = element;
while (current !== root) {
const parentElement = current.parentElement;
if (!parentElement) {
break;
}
if (parentElement instanceof HTMLDetailsElement) {
parentElement.open = true;
}
current = parentElement;
}
}
function resolveFirstInvalidFocusTarget(firstInvalid, focusSelector) {
const preferredInvalidTarget = firstInvalid.querySelector(INVALID_FOCUS_PREFERRED_SELECTOR);
if (preferredInvalidTarget instanceof HTMLElement) {
return preferredInvalidTarget;
}
try {
if (firstInvalid.matches(focusSelector)) {
return firstInvalid;
}
const fallbackTarget = firstInvalid.querySelector(focusSelector);
return fallbackTarget instanceof HTMLElement ? fallbackTarget : null;
}
catch {
return null;
}
}
const ROOT_FORM = 'rootForm';
const ERROR_MESSAGES_KEY = 'errors';
const WARNING_MESSAGES_KEY = 'warnings';
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Recursively calculates the path of a form control
* @param formGroup
* @param control
*/
function getChildEntries(container) {
if (isFormArray(container)) {
return container.controls.map((child, index) => [String(index), child]);
}
return Object.entries(container.controls);
}
function getControlPath(formGroup, control) {
// First attempt: depth-first traversal from provided root
for (const [key, child] of getChildEntries(formGroup)) {
if (child === control) {
return key;
}
if (isFormGroup(child) || isFormArray(child)) {
const subPath = getControlPath(child, control);
if (subPath) {
return `${key}.${subPath}`;
}
}
}
// Fallback: walk up the parent chain from control to root
let current = control;
const segments = [];
while (current?.parent) {
const parent = current.parent;
for (const [key, controlInParent] of getChildEntries(parent)) {
if (controlInParent === current) {
segments.unshift(key);
break;
}
}
current = parent;
if (current === formGroup) {
return segments.join('.');
}
}
// Last resort: try control.name if available
const name = control.name;
if (typeof name === 'string') {
return name;
}
return '';
}
/**
* Recursively calculates the path of a form group
* @param formGroup
* @param control
*/
function getGroupPath(formGroup, control) {
for (const [key, ctrl] of getChildEntries(formGroup)) {
if (ctrl === control) {
return key;
}
if (isFormGroup(ctrl)) {
const path = getGroupPath(ctrl, control);
if (path) {
return `${key}.${path}`;
}
}
}
return '';
}
/**
* @internal
* Internal utility for calculating form control field paths.
*
* **Not intended for external use.** This function is used internally by the library
* to determine field names for validation. Use the `name` attribute on your form controls
* instead of relying on this function.
*
* Calculates the field name of a form control: Eg: addresses.shippingAddress.street
* @param rootForm
* @param control
*/
function getFormControlField(rootForm, control) {
return getControlPath(rootForm, control);
}
/**
* @internal
* Internal utility for calculating form group field paths.
*
* **Not intended for external use.** This function is used internally by the library
* to determine field names for nested form groups.
*
* Calcuates the field name of a form group Eg: addresses.shippingAddress
* @param rootForm
* @param control
*/
function getFormGroupField(rootForm, control) {
return getGroupPath(rootForm, control);
}
/**
* @internal
* Internal utility for merging form values with disabled field values.
*
* **Not intended for external use.** This function is used internally by the library
* to include disabled field values in form submissions. Use Angular's `getRawValue()`
* method on your form if you need to access disabled field values.
*
* This utility merges the value of the form with the raw value.
* By doing this we can assure that we don't lose values of disabled form fields
*
* Security: Unsafe prototype-related keys (`__proto__`, `prototype`, `constructor`)
* are skipped during recursive merge.
* @param form
*/
function mergeValuesAndRawValues(form) {
// Deep clone both values to prevent reference sharing.
// This is necessary because:
// 1. form.value may contain object references that could be mutated elsewhere
// 2. form.getRawValue() also returns references to form control values
// 3. Without cloning, mutations to the returned object would affect the original form state
// 4. The merge operation itself requires a mutable copy to work with
// Performance note: For large forms, this may have performance implications. However,
// reference isolation is critical for maintaining form state integrity.
const value = structuredClone(form.value);
const rawValue = structuredClone(form.getRawValue());
// Recursive function to merge rawValue into value
function mergeRecursive(target, source) {
for (const key of Object.keys(source)) {
if (isUnsafePathSegment(key)) {
continue;
}
const sourceValue = source[key];
const targetValue = target[key];
if (targetValue === undefined || targetValue === null) {
// Key missing from target (e.g. disabled field) or set to null —
// copy source so raw values from disabled controls aren't dropped.
target[key] = sourceValue;
}
else if (isRecord(sourceValue) && isRecord(targetValue)) {
// If the value is an object, merge it recursively
mergeRecursive(targetValue, sourceValue);
}
// If the target already has the key with a primitive value, it's left as is to maintain references
}
}
mergeRecursive(value, rawValue);
return value;
}
function isPrimitive(value) {
return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
}
function getStringArrayError(errors, key) {
const value = errors?.[key];
return Array.isArray(value)
? value.filter((v) => typeof v === 'string')
: undefined;
}
/**
* Performs a deep-clone of an object.
*
* @deprecated Use the standard {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone structuredClone} instead.
*
* `structuredClone` correctly handles `Map`, `Set`, `RegExp`, typed arrays, and
* cyclic references; this implementation silently drops `Map` / `Set` / `RegExp`
* data and produces incorrect results on cycles. Scheduled for removal in a
* future major; see `docs/prd/PRD-bug-sweep.md` (Bundle D) for tracking.
*
* Browser Support: `structuredClone` is available in all modern browsers
* (Chrome 98+, Firefox 94+, Safari 15.4+, Edge 98+) and Node.js 17+.
*/
let cloneDeepDeprecationWarned = false;
function cloneDeep(object) {
// NOTE: `typeof ngDevMode !== 'undefined' && ngDevMode` is kept inline
// (not extracted to a helper) because Angular's build optimizer relies on
// this exact pattern for tree-shaking dev-only code from production bundles.
if (!cloneDeepDeprecationWarned &&
typeof ngDevMode !== 'undefined' &&
ngDevMode) {
cloneDeepDeprecationWarned = true;
console.warn('[ngx-vest-forms] cloneDeep is deprecated and silently drops Map/Set/RegExp values. ' +
'Use the standard structuredClone() instead.');
}
// Handle primitives (null, undefined, boolean, string, number, function)
if (isPrimitive(object)) {
return object;
}
// Handle Date
if (object instanceof Date) {
return new Date(object);
}
// Handle Array
if (Array.isArray(object)) {
return object.map((item) => cloneDeep(item));
}
// Handle Object
if (object instanceof Object) {
const clonedObject = {};
for (const key in object) {
if (Object.prototype.hasOwnProperty.call(object, key)) {
clonedObject[key] = cloneDeep(object[key]);
}
}
return clonedObject;
}
throw new Error("Unable to copy object! Its type isn't supported.");
}
/**
* Sets a value in an object at the provided field path.
*
* Supports dot and bracket notation via `parseFieldPath()`.
* Examples: `user.profile.name`, `addresses[0].street`.
*
* Security: If any path segment matches an unsafe prototype-related key
* (`__proto__`, `prototype`, `constructor`), the write is ignored.
*
* @param obj - Target object to mutate.
* @param path - Dot/bracket field path.
* @param value - Value to assign at the resolved path.
*/
function setValueAtPath(obj, path, value) {
const keys = parseFieldPath(path);
if (keys.length === 0) {
return;
}
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const segment = keys[i];
const nextSegment = keys[i + 1];
if (segment === undefined) {
continue;
}
if (isUnsafePathSegment(segment)) {
return;
}
const key = String(segment);
const next = current[key];
if (!Array.isArray(next) && !isRecord(next)) {
const shouldCreateArray = typeof nextSegment === 'number' ||
(typeof nextSegment === 'string' && /^\d+$/.test(nextSegment));
current[key] = shouldCreateArray ? [] : {};
}
current = current[key];
}
const lastSegment = keys[keys.length - 1];
if (lastSegment === undefined || isUnsafePathSegment(lastSegment)) {
return;
}
current[String(lastSegment)] = value;
}
/**
* @deprecated Use {@link setValueAtPath} instead
*/
function set(obj, path, value) {
return setValueAtPath(obj, path, value);
}
/**
* @internal
* Internal utility for collecting all form errors by field path.
*
* **Not intended for external use.** This function is used internally by the library
* to generate the form state. Use the `formState()` signal from the `scVestForm` directive
* to access form errors in your components.
*
* Traverses the form and returns the errors by path
* @param form
*/
function getAllFormErrors(form) {
const errors = {};
if (!form) {
return errors;
}
// Collect root form errors (from ValidateRootFormDirective) before processing children
if (form.enabled) {
const rootErrors = getStringArrayError(form.errors, ERROR_MESSAGES_KEY);
if (rootErrors) {
errors[ROOT_FORM] = rootErrors;
}
}
function collect(control, pathParts) {
const pathString = stringifyFieldPath(pathParts);
// Skip processing the root form control directly in NgxFormDirective
if (pathParts.length === 0 && control === form) {
// Instead, iterate its children if it's a group/array
if (isFormGroup(control) || isFormArray(control)) {
for (const [key, childControl] of getChildEntries(control)) {
const numericKey = Number(key);
const nextPath = [
// ...pathParts, // pathParts is empty here
Number.isNaN(numericKey) ? key : numericKey,
];
collect(childControl, nextPath);
}
}
return; // Stop processing for the root form itself at this level
}
if (isFormGroup(control) || isFormArray(control)) {
for (const [key, childControl] of getChildEntries(control)) {
const numericKey = Number(key);
const nextPath = [
...pathParts,
Number.isNaN(numericKey) ? key : numericKey,
];
collect(childControl, nextPath);
}
}
// Attach control errors (both errors and warnings)
if (control.enabled) {
const fieldErrors = getStringArrayError(control.errors, ERROR_MESSAGES_KEY);
if (fieldErrors) {
errors[pathString] = fieldErrors;
}
// Optionally, add warnings if present
const fieldWarnings = getStringArrayError(control.errors, WARNING_MESSAGES_KEY);
if (fieldWarnings) {
// Attach warnings as a property on the error array (non-enumerable)
// This is still done here for field-specific warnings, but not for root warnings.
if (!errors[pathString]) {
errors[pathString] = []; // Ensure array exists if only warnings are present
}
Object.defineProperty(errors[pathString], 'warnings', {
value: fieldWarnings,
enumerable: false, // Keep it non-enumerable as per previous behavior for field warnings
configurable: true,
writable: true,
});
}
}
}
collect(form, []);
// Root form errors (form.errors) are no longer processed here.
// They are handled directly in NgxFormDirective to populate formState.root.
return errors;
}
const NUMERIC_PATH_SEGMENT = /^\d+$/;
function isOpaqueLeafValue(value) {
return (value instanceof Date ||
value instanceof Map ||
value instanceof Set ||
value instanceof RegExp ||
(typeof File !== 'undefined' && value instanceof File) ||
(typeof Blob !== 'undefined' && value instanceof Blob));
}
function isTraversableValue(value) {
return typeof value === 'object' && value !== null && !isOpaqueLeafValue(value);
}
/**
* Validates a form value against a shape to catch typos in `name` or `ngModelGroup` attributes.
*
* **What it checks:**
* - Extra properties: Keys in formValue that don't exist in shape (likely typos)
* - Type mismatches: When formValue has an object but shape expects a primitive
*
* **What it does NOT check:**
* - Missing properties: Keys in shape that don't exist in formValue
* (forms build incrementally with `NgxDeepPartial`, and `@if` conditionally renders fields)
*
* Only runs in development mode.
*
* @param formVal - The current form value
* @param shape - The expected shape (created with `NgxDeepRequired<T>`)
*/
function validateShape(formVal, shape) {
if (isDevMode()) {
validateFormValueAgainstShape(formVal, shape);
}
}
/**
* Recursively validates form value keys against the shape.
* Reports warnings for extra properties and type mismatches.
*/
function validateFormValueAgainstShape(formValue, shape, path = '') {
for (const key of Object.keys(formValue)) {
const value = formValue[key];
const fieldPath = path ? `${path}.${key}` : key;
// Skip null/undefined values (valid during form initialization)
if (value == null) {
continue;
}
// For array items (numeric keys > 0), compare against the first item in shape
// since we only define one example item in the shape for arrays
const isNumericKey = NUMERIC_PATH_SEGMENT.test(key);
// Array shapes provide one example item at index 0, so every numeric key maps to '0'.
const shapeKey = isNumericKey && key !== '0' ? '0' : key;
const shapeValue = shape?.[shapeKey];
const hasShapeKey = shape != null && shapeKey in shape;
// Skip Date fields receiving empty strings (common in date picker libraries)
if (shapeValue instanceof Date && value === '') {
continue;
}
// Handle object values (recurse into nested objects)
if (typeof value === 'object') {
if (!isTraversableValue(value)) {
if (!isNumericKey && !hasShapeKey) {
logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
}
else if (!isNumericKey &&
hasShapeKey &&
(shapeValue === null || typeof shapeValue !== 'object')) {
// Type mismatch: formValue holds an opaque object (Date, Map, etc.)
// but shape declares a primitive. Recursion is intentionally skipped
// for opaque leaves, but the user still benefits from a warning.
logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
}
continue;
}
// Type mismatch: formValue has object, but shape expects primitive
if (!isNumericKey && !isTraversableValue(shapeValue)) {
logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
}
// Recurse into nested object
validateFormValueAgainstShape(value, isTraversableValue(shapeValue) ? shapeValue : {}, fieldPath);
continue;
}
// Extra property: key exists in formValue but not in shape (likely a typo)
if (!isNumericKey && !hasShapeKey) {
logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
}
}
}
const formSubmittedSignals = new WeakMap();
function getFormSubmittedSignal(ngForm) {
let submitted = formSubmittedSignals.get(ngForm);
if (!submitted) {
submitted = signal(ngForm.submitted);
formSubmittedSignals.set(ngForm, submitted);
}
return submitted;
}
function setAngularFormSubmittedState(ngForm, submitted) {
// Angular 21.x's concrete NgForm stores submitted state on
// `submittedReactive`, while AbstractFormDirective-backed implementations
// expose `_submittedReactive` and may also provide a public setter on
// `submitted`. This helper is verified against Angular 21.x in this
// repository and falls back to the first writable `submitted` setter it can
// find if a future Angular version changes the concrete field names.
//
// We try the concrete/internal signal first because NgForm overrides the
// getter-only `submitted` property at runtime in this workspace. If neither
// signal exists, fall back to the first writable `submitted` setter we can
// find on the prototype chain. If no setter exists either, callers should
// still update ngx-vest-forms' shared signal so error display state remains
// reactive even though Angular's native submitted flag cannot be changed.
const signalHost = ngForm;
const angularSignal = signalHost.submittedReactive ?? signalHost._submittedReactive;
if (angularSignal) {
angularSignal.set(submitted);
return;
}
let prototype = Object.getPrototypeOf(ngForm);
while (prototype) {
const submittedDescriptor = Object.getOwnPropertyDescriptor(prototype, 'submitted');
if (submittedDescriptor?.set) {
submittedDescriptor.set.call(ngForm, submitted);
return;
}
prototype = Object.getPrototypeOf(prototype);
}
}
/**
* Duration (in milliseconds) to keep fields marked as "in-progress" after validation.
* This prevents immediate re-triggering of bidirectional validations.
* Increased from 100ms to 500ms to give validators enough time to complete and propagate.
*/
const VALIDATION_IN_PROGRESS_TIMEOUT_MS = 500;
/**
* Main form directive for ngx-vest-forms that bridges Angular template-driven forms with Vest.js validation.
*
* This directive provides:
* - **Unidirectional data flow**: Use `[ngModel]` (not `[(ngModel)]`) with `(formValueChange)` for predictable state updates
* - **Vest.js integration**: Automatic async validators from Vest suites with field-level optimization
* - **Validation dependencies**: Configure cross-field validation triggers via `validationConfig`
* - **Form state**: Access validity, errors, and values through the `formState` signal
*
* @usageNotes
*
* ### Basic Usage
* ```html
* <form ngxVestForm [suite]="validationSuite" (formValueChange)="formValue.set($event)">
* <input name="email" [ngModel]="formValue().email" />
* </form>
* ```
*
* ### With Validation Dependencies
* ```html
* <form ngxVestForm [suite]="suite" [validationConfig]="validationConfig">
* <input name="password" [ngModel]="formValue().password" />
* <input name="confirmPassword" [ngModel]="formValue().confirmPassword" />
* </form>
* ```
* ```typescript
* validationConfig = { 'password': ['confirmPassword'] };
* ```
*
* ### Accessing Form State
* ```typescript
* vestForm = viewChild.required('vestForm', { read: FormDirective });
* isValid = computed(() => this.vestForm().formState().valid);
* ```
*
* @see {@link https://github.com/ngx-vest-forms/ngx-vest-forms} for full documentation
* @publicApi
*/
class FormDirective {
/**
* Deep-equality comparator. Defaults to `fastDeepEqual`; can be overridden
* application-wide or per-component via {@link NGX_EQUALITY_FN}.
*/
#equal;
/**
* Set to true by the onDestroy hook. Used to guard async callbacks
* (e.g. Vest `done()`) that cannot be cancelled via RxJS operators.
*/
#destroyed;
#lastSyncedFormValue;
#lastSyncedModelValue;
// Internal signal tracking changes that can affect the merged form snapshot.
// ValueChangeEvent keeps the cache fresh for blur-driven consumers like
// draft auto-save, even when a value update doesn't change form validity.
#formSnapshotTick;
/**
* LinkedSignal that computes form values from Angular form state.
* This eliminates timing issues with the previous dual-effect pattern.
*/
#formValueSignal;
/**
* Track the Angular form status as a signal for advanced status flags
*/
#statusSignal;
/**
* Reactive counter incremented on any focusout within the form.
* This guarantees recomputation for every blur/tab interaction,
* even when the form's aggregate touched flag is already true.
*/
#blurTick;
/**
* Counter signal tied to validation feedback updates so `formState()` can
* recompute whenever the underlying error set changes.
*/
#validationFeedbackTick;
constructor() {
this.ngForm = inject(NgForm, { self: true });
this.elementRef = inject((ElementRef));
this.destroyRef = inject(DestroyRef);
this.cdr = inject(ChangeDetectorRef);
this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
/**
* Deep-equality comparator. Defaults to `fastDeepEqual`; can be overridden
* application-wide or per-component via {@link NGX_EQUALITY_FN}.
*/
this.#equal = inject(NGX_EQUALITY_FN);
/**
* Public signal storing field warnings keyed by field path.
* This allows warnings to be stored and displayed without affecting field validity.
* Angular's control.errors !== null marks a field as invalid, so we store warnings
* separately when they exist without errors.
*/
this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : /* istanbul ignore next */ []));
/**
* Set to true by the onDestroy hook. Used to guard async callbacks
* (e.g. Vest `done()`) that cannot be cancelled via RxJS operators.
*/
this.#destroyed = false;
this.#lastSyncedFormValue = null;
this.#lastSyncedModelValue = null;
// Internal signal tracking changes that can affect the merged form snapshot.
// ValueChangeEvent keeps the cache fresh for blur-driven consumers like
// draft auto-save, even when a value update doesn't change form validity.
this.#formSnapshotTick = toSignal(this.ngForm.form.events.pipe(filter((event) => event instanceof ValueChangeEvent || event instanceof StatusChangeEvent), scan((count) => count + 1, 0), startWith(0)), { initialValue: 0 });
/**
* LinkedSignal that computes form values from Angular form state.
* This eliminates timing issues with the previous dual-effect pattern.
*/
this.#formValueSignal = linkedSignal(() => {
// Track changes that affect the merged form snapshot.
this.#formSnapshotTick();
if (Object.keys(this.ngForm.form.controls).length === 0) {
// No controls remain (e.g. dynamic group removal): expose `null` so
// consumers don't see ghost data from a previous form shape.
return null;
}
return mergeValuesAndRawValues(this.ngForm.form);
}, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : /* istanbul ignore next */ []));
/**
* Track the Angular form status as a signal for advanced status flags
*/
this.#statusSignal = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
/**
* Reactive counter incremented on any focusout within the form.
* This guarantees recomputation for every blur/tab interaction,
* even when the form's aggregate touched flag is already true.
*/
th