UNPKG

ngx-vest-forms

Version:

Opinionated template-driven forms library for Angular with Vest.js integration

1,282 lines (1,269 loc) 235 kB
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