ngx-vest-forms 2.6.0 → 2.7.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 +115 -1
- package/fesm2022/ngx-vest-forms.mjs +803 -237
- package/fesm2022/ngx-vest-forms.mjs.map +1 -1
- package/package.json +3 -2
- package/types/ngx-vest-forms.d.ts +120 -30
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
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,
|
|
2
|
+
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';
|
|
3
|
+
import { isFormArray, isFormGroup, NgForm, ValueChangeEvent, StatusChangeEvent, 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
|
-
import { startWith, merge,
|
|
5
|
+
import { filter, scan, startWith, merge, map, switchMap, take, of, distinctUntilChanged, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @deprecated Use NGX_ERROR_DISPLAY_MODE_TOKEN instead
|
|
@@ -113,6 +113,20 @@ const NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN = new InjectionToken('NgxValidationCo
|
|
|
113
113
|
function isPrimitive$1(value) {
|
|
114
114
|
return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Records that we've started comparing the pair (a, b). Re-entering the same
|
|
118
|
+
* pair during recursion (cycle, or repeated DAG path) short-circuits to `true`
|
|
119
|
+
* — correct because if the prior descent had returned `false`, the outer call
|
|
120
|
+
* would already have short-circuited before we reach the second visit.
|
|
121
|
+
*/
|
|
122
|
+
function rememberPair(seen, a, b) {
|
|
123
|
+
let targets = seen.get(a);
|
|
124
|
+
if (!targets) {
|
|
125
|
+
targets = new WeakSet();
|
|
126
|
+
seen.set(a, targets);
|
|
127
|
+
}
|
|
128
|
+
targets.add(b);
|
|
129
|
+
}
|
|
116
130
|
/**
|
|
117
131
|
* @internal
|
|
118
132
|
* Internal utility for shallow equality checks.
|
|
@@ -207,11 +221,13 @@ function shallowEqual(obj1, obj2) {
|
|
|
207
221
|
* - Plain objects (with recursive deep comparison)
|
|
208
222
|
* - Date objects (by timestamp comparison)
|
|
209
223
|
* - RegExp objects (by source and flags comparison)
|
|
210
|
-
* - Set objects (
|
|
211
|
-
* - Map objects (
|
|
224
|
+
* - Set objects (reference equality only)
|
|
225
|
+
* - Map objects (reference equality only)
|
|
226
|
+
* - Functions (reference equality only — distinct function instances are never equal,
|
|
227
|
+
* even if their source code is identical)
|
|
212
228
|
*
|
|
213
229
|
* **Safety Features:**
|
|
214
|
-
* - **Circular reference
|
|
230
|
+
* - **Circular reference handling**: Tracks visited object pairs with `WeakMap<object, WeakSet<object>>`
|
|
215
231
|
* - **Type coercion prevention**: Strict type checking before comparison
|
|
216
232
|
* - **Null safety**: Proper handling of null and undefined values
|
|
217
233
|
*
|
|
@@ -223,7 +239,8 @@ function shallowEqual(obj1, obj2) {
|
|
|
223
239
|
* ///
|
|
224
240
|
* /// Memory usage:
|
|
225
241
|
* /// JSON.stringify: Creates temporary strings (high GC pressure)
|
|
226
|
-
* /// fastDeepEqual:
|
|
242
|
+
* /// fastDeepEqual: One small WeakMap of visited object pairs is allocated lazily on
|
|
243
|
+
* /// first nested-container descent; primitive-only comparisons allocate nothing.
|
|
227
244
|
* ```
|
|
228
245
|
*
|
|
229
246
|
* **Typical Usage in Forms:**
|
|
@@ -239,91 +256,222 @@ function shallowEqual(obj1, obj2) {
|
|
|
239
256
|
*
|
|
240
257
|
* @param obj1 - First object to compare
|
|
241
258
|
* @param obj2 - Second object to compare
|
|
242
|
-
*
|
|
259
|
+
*
|
|
260
|
+
* Cyclic arrays and plain objects are compared structurally by tracking visited object
|
|
261
|
+
* pairs. Distinct cyclic graphs with the same structure compare equal. `Date` and
|
|
262
|
+
* `RegExp` values compare structurally. `Map` and `Set` values compare by reference
|
|
263
|
+
* only, so distinct instances are considered different even if their contents match.
|
|
264
|
+
*
|
|
243
265
|
* @returns true if objects are deeply equal by value
|
|
244
266
|
*/
|
|
245
|
-
function fastDeepEqual(obj1, obj2
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
267
|
+
function fastDeepEqual(obj1, obj2) {
|
|
268
|
+
return fastDeepEqualInternal(obj1, obj2, undefined);
|
|
269
|
+
}
|
|
270
|
+
function fastDeepEqualInternal(obj1, obj2, seen) {
|
|
271
|
+
// Object.is gives correct semantics for NaN and ±0 — important for numeric
|
|
272
|
+
// form values where `fastDeepEqual(NaN, NaN)` should be true.
|
|
273
|
+
if (Object.is(obj1, obj2)) {
|
|
251
274
|
return true;
|
|
252
275
|
}
|
|
253
276
|
if (obj1 == null || obj2 == null) {
|
|
254
|
-
return
|
|
277
|
+
return false;
|
|
255
278
|
}
|
|
256
279
|
if (typeof obj1 !== typeof obj2) {
|
|
257
280
|
return false;
|
|
258
281
|
}
|
|
282
|
+
// Functions use reference-only equality. Object.is at the top already returned
|
|
283
|
+
// true for identical references, so reaching here means the references differ.
|
|
284
|
+
if (typeof obj1 === 'function') {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
259
287
|
if (isPrimitive$1(obj1) || isPrimitive$1(obj2)) {
|
|
260
|
-
return
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
// Handle Date / RegExp first — they have value semantics and can't contain cycles,
|
|
291
|
+
// so they don't need pair tracking.
|
|
292
|
+
if (obj1 instanceof Date || obj2 instanceof Date) {
|
|
293
|
+
return (obj1 instanceof Date &&
|
|
294
|
+
obj2 instanceof Date &&
|
|
295
|
+
obj1.getTime() === obj2.getTime());
|
|
296
|
+
}
|
|
297
|
+
if (obj1 instanceof RegExp || obj2 instanceof RegExp) {
|
|
298
|
+
return (obj1 instanceof RegExp &&
|
|
299
|
+
obj2 instanceof RegExp &&
|
|
300
|
+
obj1.source === obj2.source &&
|
|
301
|
+
obj1.flags === obj2.flags);
|
|
302
|
+
}
|
|
303
|
+
// Intentional contract: distinct Set / Map instances compare by reference only.
|
|
304
|
+
if (obj1 instanceof Set || obj2 instanceof Set) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (obj1 instanceof Map || obj2 instanceof Map) {
|
|
308
|
+
return false;
|
|
261
309
|
}
|
|
262
|
-
// Handle arrays early for performance (common in forms)
|
|
263
310
|
if (Array.isArray(obj1) !== Array.isArray(obj2)) {
|
|
264
311
|
return false;
|
|
265
312
|
}
|
|
313
|
+
// Cycle / repeated-pair guard for traversable containers.
|
|
314
|
+
// Allocates lazily on first nested-object descent.
|
|
315
|
+
const a = obj1;
|
|
316
|
+
const b = obj2;
|
|
317
|
+
if (seen?.get(a)?.has(b)) {
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
seen ??= new WeakMap();
|
|
321
|
+
rememberPair(seen, a, b);
|
|
266
322
|
if (Array.isArray(obj1)) {
|
|
267
|
-
// We know obj2 is also an array here
|
|
268
323
|
const arr2 = obj2;
|
|
269
324
|
if (obj1.length !== arr2.length) {
|
|
270
325
|
return false;
|
|
271
326
|
}
|
|
272
327
|
for (let i = 0; i < obj1.length; i++) {
|
|
273
|
-
if (!
|
|
328
|
+
if (!fastDeepEqualInternal(obj1[i], arr2[i], seen)) {
|
|
274
329
|
return false;
|
|
275
330
|
}
|
|
276
331
|
}
|
|
277
332
|
return true;
|
|
278
333
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (obj1 instanceof RegExp) {
|
|
285
|
-
return (obj2 instanceof RegExp &&
|
|
286
|
-
obj1.source === obj2.source &&
|
|
287
|
-
obj1.flags === obj2.flags);
|
|
334
|
+
// Plain objects
|
|
335
|
+
const keys1 = Object.keys(a);
|
|
336
|
+
const o2 = b;
|
|
337
|
+
if (keys1.length !== Object.keys(o2).length) {
|
|
338
|
+
return false;
|
|
288
339
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (!(obj2 instanceof Set) || obj1.size !== obj2.size) {
|
|
340
|
+
for (const key of keys1) {
|
|
341
|
+
if (!Object.hasOwn(o2, key)) {
|
|
292
342
|
return false;
|
|
293
343
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
344
|
+
if (!fastDeepEqualInternal(a[key], o2[key], seen)) {
|
|
345
|
+
return false;
|
|
298
346
|
}
|
|
299
|
-
return true;
|
|
300
347
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Injection token for the deep-equality function used internally by
|
|
353
|
+
* {@link FormDirective} for change detection — `formValueChange`
|
|
354
|
+
* `distinctUntilChanged`, the form↔model two-way sync effect, and the
|
|
355
|
+
* `formState` signal's structural comparator.
|
|
356
|
+
*
|
|
357
|
+
* The default factory returns {@link fastDeepEqual}. Override this token to
|
|
358
|
+
* plug in a smaller or differently-tuned comparator (e.g. `dequal/lite`,
|
|
359
|
+
* `lodash.isEqual`) without forking the directive.
|
|
360
|
+
*
|
|
361
|
+
* @example Bring-your-own equality at the application level
|
|
362
|
+
* ```ts
|
|
363
|
+
* import { dequal } from 'dequal/lite';
|
|
364
|
+
* import { NGX_EQUALITY_FN } from 'ngx-vest-forms';
|
|
365
|
+
*
|
|
366
|
+
* export const appConfig: ApplicationConfig = {
|
|
367
|
+
* providers: [
|
|
368
|
+
* { provide: NGX_EQUALITY_FN, useValue: dequal },
|
|
369
|
+
* ],
|
|
370
|
+
* };
|
|
371
|
+
* ```
|
|
372
|
+
*
|
|
373
|
+
* @example Per-component override (e.g. for tests)
|
|
374
|
+
* ```ts
|
|
375
|
+
* @Component({
|
|
376
|
+
* providers: [
|
|
377
|
+
* { provide: NGX_EQUALITY_FN, useValue: (a, b) => a === b },
|
|
378
|
+
* ],
|
|
379
|
+
* })
|
|
380
|
+
* export class TestFormComponent {}
|
|
381
|
+
* ```
|
|
382
|
+
*
|
|
383
|
+
* @default {@link fastDeepEqual}
|
|
384
|
+
*/
|
|
385
|
+
const NGX_EQUALITY_FN = new InjectionToken('NgxEqualityFn', {
|
|
386
|
+
providedIn: 'root',
|
|
387
|
+
factory: () => fastDeepEqual,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Schedules a callback to run after a delay, automatically cancelling it if the
|
|
392
|
+
* provided `DestroyRef` fires before the timer expires.
|
|
393
|
+
*
|
|
394
|
+
* @param callback - Function to invoke after the delay.
|
|
395
|
+
* @param delayMs - Delay in milliseconds (passed to `setTimeout`).
|
|
396
|
+
* @param destroyRef - Angular `DestroyRef` to register auto-cancellation against.
|
|
397
|
+
* @returns A cancel function; calling it before the timer fires prevents the
|
|
398
|
+
* callback from running and unregisters the destroy listener.
|
|
399
|
+
*/
|
|
400
|
+
function scheduleTimeout(callback, delayMs, destroyRef) {
|
|
401
|
+
let cancelled = false;
|
|
402
|
+
let unregisterCalled = false;
|
|
403
|
+
// eslint-disable-next-line prefer-const -- assigned after onDestroy returns
|
|
404
|
+
let unregisterDestroy;
|
|
405
|
+
// Idempotent unregister wrapper so all paths (timer-fires, explicit-cancel,
|
|
406
|
+
// and onDestroy) can call it safely without double-removal.
|
|
407
|
+
const safeUnregister = () => {
|
|
408
|
+
if (unregisterCalled)
|
|
409
|
+
return;
|
|
410
|
+
unregisterCalled = true;
|
|
411
|
+
unregisterDestroy?.();
|
|
412
|
+
};
|
|
413
|
+
const handle = setTimeout(() => {
|
|
414
|
+
safeUnregister();
|
|
415
|
+
if (!cancelled) {
|
|
416
|
+
callback();
|
|
305
417
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
418
|
+
}, delayMs);
|
|
419
|
+
unregisterDestroy = destroyRef.onDestroy(() => {
|
|
420
|
+
cancelled = true;
|
|
421
|
+
clearTimeout(handle);
|
|
422
|
+
safeUnregister();
|
|
423
|
+
});
|
|
424
|
+
return () => {
|
|
425
|
+
if (!cancelled) {
|
|
426
|
+
cancelled = true;
|
|
427
|
+
clearTimeout(handle);
|
|
428
|
+
safeUnregister();
|
|
311
429
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Schedules a microtask callback, automatically suppressing it if the provided
|
|
434
|
+
* `DestroyRef` fires before the microtask runs.
|
|
435
|
+
*
|
|
436
|
+
* Since `queueMicrotask` has no native cancellation API, this relies on a
|
|
437
|
+
* destroyed flag that is set inside the registered `onDestroy` hook.
|
|
438
|
+
*
|
|
439
|
+
* @param callback - Function to invoke in the next microtask checkpoint.
|
|
440
|
+
* @param destroyRef - Angular `DestroyRef` to register auto-cancellation against.
|
|
441
|
+
* @returns A cancel function; calling it before the microtask runs prevents the
|
|
442
|
+
* callback from executing and unregisters the destroy listener.
|
|
443
|
+
*/
|
|
444
|
+
function scheduleMicrotask(callback, destroyRef) {
|
|
445
|
+
let cancelled = false;
|
|
446
|
+
let unregisterCalled = false;
|
|
447
|
+
// Register the onDestroy listener before queuing the microtask so that
|
|
448
|
+
// `unregisterDestroy` is always defined when the microtask fires.
|
|
449
|
+
const unregisterDestroy = destroyRef.onDestroy(() => {
|
|
450
|
+
cancelled = true;
|
|
451
|
+
safeUnregister();
|
|
452
|
+
});
|
|
453
|
+
// Idempotent unregister wrapper so all paths (microtask-fires, explicit-cancel,
|
|
454
|
+
// and onDestroy) can call it safely without double-removal.
|
|
455
|
+
function safeUnregister() {
|
|
456
|
+
if (unregisterCalled)
|
|
457
|
+
return;
|
|
458
|
+
unregisterCalled = true;
|
|
459
|
+
unregisterDestroy();
|
|
319
460
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
461
|
+
queueMicrotask(() => {
|
|
462
|
+
// Always clean up the destroy listener when the microtask fires,
|
|
463
|
+
// regardless of whether the callback is suppressed.
|
|
464
|
+
safeUnregister();
|
|
465
|
+
if (!cancelled) {
|
|
466
|
+
callback();
|
|
324
467
|
}
|
|
325
|
-
}
|
|
326
|
-
return
|
|
468
|
+
});
|
|
469
|
+
return () => {
|
|
470
|
+
if (!cancelled) {
|
|
471
|
+
cancelled = true;
|
|
472
|
+
safeUnregister();
|
|
473
|
+
}
|
|
474
|
+
};
|
|
327
475
|
}
|
|
328
476
|
|
|
329
477
|
/**
|
|
@@ -345,6 +493,7 @@ function fastDeepEqual(obj1, obj2, maxDepth = 10) {
|
|
|
345
493
|
* ```
|
|
346
494
|
*/
|
|
347
495
|
const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
496
|
+
const LOG_PREFIX$1 = '[ngx-vest-forms] field-path.utils';
|
|
348
497
|
/**
|
|
349
498
|
* @internal
|
|
350
499
|
* Returns whether a path segment is unsafe for object writes/merges.
|
|
@@ -391,11 +540,26 @@ function isUnsafePathSegment(segment) {
|
|
|
391
540
|
function parseFieldPath(path) {
|
|
392
541
|
if (!path)
|
|
393
542
|
return [];
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.
|
|
543
|
+
// Normalize bracket notation to dot notation first so malformed inputs like
|
|
544
|
+
// 'a.[0]' (which becomes 'a..0') are caught alongside 'a..b', '.a', 'a.', '.'.
|
|
545
|
+
const startsWithBracket = path.startsWith('[');
|
|
546
|
+
const segments = path
|
|
547
|
+
.replaceAll(/\[(\d+)\]/g, '.$1')
|
|
548
|
+
.split('.');
|
|
549
|
+
// Empty segments after normalization signal a malformed path. The single
|
|
550
|
+
// legitimate case is a leading empty produced by a path that originally
|
|
551
|
+
// started with '[' (e.g. '[0].x' → '.0.x' → ['', '0', 'x']).
|
|
552
|
+
for (let i = 0; i < segments.length; i++) {
|
|
553
|
+
if (segments[i] === '' && !(i === 0 && startsWithBracket)) {
|
|
554
|
+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
|
|
555
|
+
console.warn(`${LOG_PREFIX$1}: Invalid field path '${path}'. Leading dots, trailing dots, consecutive dots, and '.[' separators are not allowed.`);
|
|
556
|
+
}
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return segments
|
|
561
|
+
.filter((part) => part !== '')
|
|
562
|
+
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
|
|
399
563
|
}
|
|
400
564
|
/**
|
|
401
565
|
* Converts a Standard Schema path array (e.g. `['addresses', 0, 'street']`)
|
|
@@ -654,8 +818,9 @@ function mergeValuesAndRawValues(form) {
|
|
|
654
818
|
}
|
|
655
819
|
const sourceValue = source[key];
|
|
656
820
|
const targetValue = target[key];
|
|
657
|
-
if (targetValue === undefined) {
|
|
658
|
-
//
|
|
821
|
+
if (targetValue === undefined || targetValue === null) {
|
|
822
|
+
// Key missing from target (e.g. disabled field) or set to null —
|
|
823
|
+
// copy source so raw values from disabled controls aren't dropped.
|
|
659
824
|
target[key] = sourceValue;
|
|
660
825
|
}
|
|
661
826
|
else if (isRecord(sourceValue) && isRecord(targetValue)) {
|
|
@@ -678,15 +843,30 @@ function getStringArrayError(errors, key) {
|
|
|
678
843
|
: undefined;
|
|
679
844
|
}
|
|
680
845
|
/**
|
|
681
|
-
* Performs a deep-clone of an object
|
|
682
|
-
*
|
|
846
|
+
* Performs a deep-clone of an object.
|
|
847
|
+
*
|
|
848
|
+
* @deprecated Use the standard {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone structuredClone} instead.
|
|
683
849
|
*
|
|
684
|
-
*
|
|
850
|
+
* `structuredClone` correctly handles `Map`, `Set`, `RegExp`, typed arrays, and
|
|
851
|
+
* cyclic references; this implementation silently drops `Map` / `Set` / `RegExp`
|
|
852
|
+
* data and produces incorrect results on cycles. Scheduled for removal in a
|
|
853
|
+
* future major; see `docs/prd/PRD-bug-sweep.md` (Bundle D) for tracking.
|
|
685
854
|
*
|
|
686
|
-
* Browser Support: structuredClone is available in all modern browsers
|
|
687
|
-
*
|
|
855
|
+
* Browser Support: `structuredClone` is available in all modern browsers
|
|
856
|
+
* (Chrome 98+, Firefox 94+, Safari 15.4+, Edge 98+) and Node.js 17+.
|
|
688
857
|
*/
|
|
858
|
+
let cloneDeepDeprecationWarned = false;
|
|
689
859
|
function cloneDeep(object) {
|
|
860
|
+
// NOTE: `typeof ngDevMode !== 'undefined' && ngDevMode` is kept inline
|
|
861
|
+
// (not extracted to a helper) because Angular's build optimizer relies on
|
|
862
|
+
// this exact pattern for tree-shaking dev-only code from production bundles.
|
|
863
|
+
if (!cloneDeepDeprecationWarned &&
|
|
864
|
+
typeof ngDevMode !== 'undefined' &&
|
|
865
|
+
ngDevMode) {
|
|
866
|
+
cloneDeepDeprecationWarned = true;
|
|
867
|
+
console.warn('[ngx-vest-forms] cloneDeep is deprecated and silently drops Map/Set/RegExp values. ' +
|
|
868
|
+
'Use the standard structuredClone() instead.');
|
|
869
|
+
}
|
|
690
870
|
// Handle primitives (null, undefined, boolean, string, number, function)
|
|
691
871
|
if (isPrimitive(object)) {
|
|
692
872
|
return object;
|
|
@@ -732,6 +912,7 @@ function setValueAtPath(obj, path, value) {
|
|
|
732
912
|
let current = obj;
|
|
733
913
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
734
914
|
const segment = keys[i];
|
|
915
|
+
const nextSegment = keys[i + 1];
|
|
735
916
|
if (segment === undefined) {
|
|
736
917
|
continue;
|
|
737
918
|
}
|
|
@@ -740,8 +921,10 @@ function setValueAtPath(obj, path, value) {
|
|
|
740
921
|
}
|
|
741
922
|
const key = String(segment);
|
|
742
923
|
const next = current[key];
|
|
743
|
-
if (!isRecord(next)) {
|
|
744
|
-
|
|
924
|
+
if (!Array.isArray(next) && !isRecord(next)) {
|
|
925
|
+
const shouldCreateArray = typeof nextSegment === 'number' ||
|
|
926
|
+
(typeof nextSegment === 'string' && /^\d+$/.test(nextSegment));
|
|
927
|
+
current[key] = shouldCreateArray ? [] : {};
|
|
745
928
|
}
|
|
746
929
|
current = current[key];
|
|
747
930
|
}
|
|
@@ -836,6 +1019,18 @@ function getAllFormErrors(form) {
|
|
|
836
1019
|
return errors;
|
|
837
1020
|
}
|
|
838
1021
|
|
|
1022
|
+
const NUMERIC_PATH_SEGMENT = /^\d+$/;
|
|
1023
|
+
function isOpaqueLeafValue(value) {
|
|
1024
|
+
return (value instanceof Date ||
|
|
1025
|
+
value instanceof Map ||
|
|
1026
|
+
value instanceof Set ||
|
|
1027
|
+
value instanceof RegExp ||
|
|
1028
|
+
(typeof File !== 'undefined' && value instanceof File) ||
|
|
1029
|
+
(typeof Blob !== 'undefined' && value instanceof Blob));
|
|
1030
|
+
}
|
|
1031
|
+
function isTraversableValue(value) {
|
|
1032
|
+
return typeof value === 'object' && value !== null && !isOpaqueLeafValue(value);
|
|
1033
|
+
}
|
|
839
1034
|
/**
|
|
840
1035
|
* Validates a form value against a shape to catch typos in `name` or `ngModelGroup` attributes.
|
|
841
1036
|
*
|
|
@@ -871,26 +1066,41 @@ function validateFormValueAgainstShape(formValue, shape, path = '') {
|
|
|
871
1066
|
}
|
|
872
1067
|
// For array items (numeric keys > 0), compare against the first item in shape
|
|
873
1068
|
// since we only define one example item in the shape for arrays
|
|
874
|
-
const isNumericKey =
|
|
875
|
-
|
|
1069
|
+
const isNumericKey = NUMERIC_PATH_SEGMENT.test(key);
|
|
1070
|
+
// Array shapes provide one example item at index 0, so every numeric key maps to '0'.
|
|
1071
|
+
const shapeKey = isNumericKey && key !== '0' ? '0' : key;
|
|
876
1072
|
const shapeValue = shape?.[shapeKey];
|
|
1073
|
+
const hasShapeKey = shape != null && shapeKey in shape;
|
|
877
1074
|
// Skip Date fields receiving empty strings (common in date picker libraries)
|
|
878
1075
|
if (shapeValue instanceof Date && value === '') {
|
|
879
1076
|
continue;
|
|
880
1077
|
}
|
|
881
1078
|
// Handle object values (recurse into nested objects)
|
|
882
1079
|
if (typeof value === 'object') {
|
|
1080
|
+
if (!isTraversableValue(value)) {
|
|
1081
|
+
if (!isNumericKey && !hasShapeKey) {
|
|
1082
|
+
logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
|
|
1083
|
+
}
|
|
1084
|
+
else if (!isNumericKey &&
|
|
1085
|
+
hasShapeKey &&
|
|
1086
|
+
(shapeValue === null || typeof shapeValue !== 'object')) {
|
|
1087
|
+
// Type mismatch: formValue holds an opaque object (Date, Map, etc.)
|
|
1088
|
+
// but shape declares a primitive. Recursion is intentionally skipped
|
|
1089
|
+
// for opaque leaves, but the user still benefits from a warning.
|
|
1090
|
+
logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
|
|
1091
|
+
}
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
883
1094
|
// Type mismatch: formValue has object, but shape expects primitive
|
|
884
|
-
if (!isNumericKey &&
|
|
885
|
-
(typeof shapeValue !== 'object' || shapeValue === null)) {
|
|
1095
|
+
if (!isNumericKey && !isTraversableValue(shapeValue)) {
|
|
886
1096
|
logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
|
|
887
1097
|
}
|
|
888
1098
|
// Recurse into nested object
|
|
889
|
-
validateFormValueAgainstShape(value, shapeValue
|
|
1099
|
+
validateFormValueAgainstShape(value, isTraversableValue(shapeValue) ? shapeValue : {}, fieldPath);
|
|
890
1100
|
continue;
|
|
891
1101
|
}
|
|
892
1102
|
// Extra property: key exists in formValue but not in shape (likely a typo)
|
|
893
|
-
if (!isNumericKey &&
|
|
1103
|
+
if (!isNumericKey && !hasShapeKey) {
|
|
894
1104
|
logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
|
|
895
1105
|
}
|
|
896
1106
|
}
|
|
@@ -981,12 +1191,22 @@ const VALIDATION_IN_PROGRESS_TIMEOUT_MS = 500;
|
|
|
981
1191
|
* @publicApi
|
|
982
1192
|
*/
|
|
983
1193
|
class FormDirective {
|
|
984
|
-
|
|
985
|
-
|
|
1194
|
+
/**
|
|
1195
|
+
* Deep-equality comparator. Defaults to `fastDeepEqual`; can be overridden
|
|
1196
|
+
* application-wide or per-component via {@link NGX_EQUALITY_FN}.
|
|
1197
|
+
*/
|
|
1198
|
+
#equal;
|
|
1199
|
+
/**
|
|
1200
|
+
* Set to true by the onDestroy hook. Used to guard async callbacks
|
|
1201
|
+
* (e.g. Vest `done()`) that cannot be cancelled via RxJS operators.
|
|
1202
|
+
*/
|
|
1203
|
+
#destroyed;
|
|
986
1204
|
#lastSyncedFormValue;
|
|
987
1205
|
#lastSyncedModelValue;
|
|
988
|
-
// Internal signal tracking
|
|
989
|
-
|
|
1206
|
+
// Internal signal tracking changes that can affect the merged form snapshot.
|
|
1207
|
+
// ValueChangeEvent keeps the cache fresh for blur-driven consumers like
|
|
1208
|
+
// draft auto-save, even when a value update doesn't change form validity.
|
|
1209
|
+
#formSnapshotTick;
|
|
990
1210
|
/**
|
|
991
1211
|
* LinkedSignal that computes form values from Angular form state.
|
|
992
1212
|
* This eliminates timing issues with the previous dual-effect pattern.
|
|
@@ -1013,36 +1233,43 @@ class FormDirective {
|
|
|
1013
1233
|
this.destroyRef = inject(DestroyRef);
|
|
1014
1234
|
this.cdr = inject(ChangeDetectorRef);
|
|
1015
1235
|
this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
|
|
1236
|
+
/**
|
|
1237
|
+
* Deep-equality comparator. Defaults to `fastDeepEqual`; can be overridden
|
|
1238
|
+
* application-wide or per-component via {@link NGX_EQUALITY_FN}.
|
|
1239
|
+
*/
|
|
1240
|
+
this.#equal = inject(NGX_EQUALITY_FN);
|
|
1016
1241
|
/**
|
|
1017
1242
|
* Public signal storing field warnings keyed by field path.
|
|
1018
1243
|
* This allows warnings to be stored and displayed without affecting field validity.
|
|
1019
1244
|
* Angular's control.errors !== null marks a field as invalid, so we store warnings
|
|
1020
1245
|
* separately when they exist without errors.
|
|
1021
1246
|
*/
|
|
1022
|
-
this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : []));
|
|
1023
|
-
|
|
1024
|
-
|
|
1247
|
+
this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : /* istanbul ignore next */ []));
|
|
1248
|
+
/**
|
|
1249
|
+
* Set to true by the onDestroy hook. Used to guard async callbacks
|
|
1250
|
+
* (e.g. Vest `done()`) that cannot be cancelled via RxJS operators.
|
|
1251
|
+
*/
|
|
1252
|
+
this.#destroyed = false;
|
|
1025
1253
|
this.#lastSyncedFormValue = null;
|
|
1026
1254
|
this.#lastSyncedModelValue = null;
|
|
1027
|
-
// Internal signal tracking
|
|
1028
|
-
|
|
1255
|
+
// Internal signal tracking changes that can affect the merged form snapshot.
|
|
1256
|
+
// ValueChangeEvent keeps the cache fresh for blur-driven consumers like
|
|
1257
|
+
// draft auto-save, even when a value update doesn't change form validity.
|
|
1258
|
+
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 });
|
|
1029
1259
|
/**
|
|
1030
1260
|
* LinkedSignal that computes form values from Angular form state.
|
|
1031
1261
|
* This eliminates timing issues with the previous dual-effect pattern.
|
|
1032
1262
|
*/
|
|
1033
1263
|
this.#formValueSignal = linkedSignal(() => {
|
|
1034
|
-
// Track form
|
|
1035
|
-
this.#
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
return
|
|
1040
|
-
}
|
|
1041
|
-
else if (this.#lastLinkedValue !== null) {
|
|
1042
|
-
return this.#lastLinkedValue;
|
|
1264
|
+
// Track changes that affect the merged form snapshot.
|
|
1265
|
+
this.#formSnapshotTick();
|
|
1266
|
+
if (Object.keys(this.ngForm.form.controls).length === 0) {
|
|
1267
|
+
// No controls remain (e.g. dynamic group removal): expose `null` so
|
|
1268
|
+
// consumers don't see ghost data from a previous form shape.
|
|
1269
|
+
return null;
|
|
1043
1270
|
}
|
|
1044
|
-
return
|
|
1045
|
-
}, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : []));
|
|
1271
|
+
return mergeValuesAndRawValues(this.ngForm.form);
|
|
1272
|
+
}, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : /* istanbul ignore next */ []));
|
|
1046
1273
|
/**
|
|
1047
1274
|
* Track the Angular form status as a signal for advanced status flags
|
|
1048
1275
|
*/
|
|
@@ -1052,7 +1279,7 @@ class FormDirective {
|
|
|
1052
1279
|
* This guarantees recomputation for every blur/tab interaction,
|
|
1053
1280
|
* even when the form's aggregate touched flag is already true.
|
|
1054
1281
|
*/
|
|
1055
|
-
this.#blurTick = signal(0, ...(ngDevMode ? [{ debugName: "#blurTick" }] : []));
|
|
1282
|
+
this.#blurTick = signal(0, ...(ngDevMode ? [{ debugName: "#blurTick" }] : /* istanbul ignore next */ []));
|
|
1056
1283
|
/**
|
|
1057
1284
|
* Computed signal that returns field paths for all touched (or submitted) leaf controls.
|
|
1058
1285
|
* Updates reactively when controls are touched (blur) or when form status changes.
|
|
@@ -1066,7 +1293,7 @@ class FormDirective {
|
|
|
1066
1293
|
this.#blurTick();
|
|
1067
1294
|
this.#statusSignal();
|
|
1068
1295
|
return this.#collectTouchedPaths(this.ngForm.form, this.ngForm.submitted);
|
|
1069
|
-
}, ...(ngDevMode ? [{ debugName: "touchedFieldPaths" }] : []));
|
|
1296
|
+
}, ...(ngDevMode ? [{ debugName: "touchedFieldPaths" }] : /* istanbul ignore next */ []));
|
|
1070
1297
|
/**
|
|
1071
1298
|
* Computed signal for form state with validity and errors.
|
|
1072
1299
|
* Used by templates and tests as vestForm.formState().valid/errors
|
|
@@ -1083,7 +1310,7 @@ class FormDirective {
|
|
|
1083
1310
|
errors: getAllFormErrors(this.ngForm.form),
|
|
1084
1311
|
value: this.#formValueSignal(),
|
|
1085
1312
|
};
|
|
1086
|
-
}, { ...(ngDevMode ? { debugName: "formState" } : {}), equal: (a, b) => {
|
|
1313
|
+
}, { ...(ngDevMode ? { debugName: "formState" } : /* istanbul ignore next */ {}), equal: (a, b) => {
|
|
1087
1314
|
// Fast path: reference equality
|
|
1088
1315
|
if (a === b)
|
|
1089
1316
|
return true;
|
|
@@ -1092,29 +1319,29 @@ class FormDirective {
|
|
|
1092
1319
|
return false;
|
|
1093
1320
|
// Deep equality check for form state properties
|
|
1094
1321
|
return (a.valid === b.valid &&
|
|
1095
|
-
|
|
1096
|
-
|
|
1322
|
+
this.#equal(a.errors, b.errors) &&
|
|
1323
|
+
this.#equal(a.value, b.value));
|
|
1097
1324
|
} });
|
|
1098
1325
|
/**
|
|
1099
1326
|
* The value of the form, this is needed for the validation part.
|
|
1100
1327
|
* Using input() here because two-way binding is provided via formValueChange output.
|
|
1101
1328
|
* In the minimal core directive (form-core.directive.ts), this would be model() instead.
|
|
1102
1329
|
*/
|
|
1103
|
-
this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
|
|
1330
|
+
this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : /* istanbul ignore next */ []));
|
|
1104
1331
|
/**
|
|
1105
1332
|
* Static vest suite that will be used to feed our angular validators.
|
|
1106
1333
|
* Accepts both NgxVestSuite and NgxTypedVestSuite through compatible type signatures.
|
|
1107
1334
|
* NgxTypedVestSuite<T> is assignable to NgxVestSuite<T> due to bivariance and
|
|
1108
1335
|
* FormFieldName<T> (string literal union) being assignable to string.
|
|
1109
1336
|
*/
|
|
1110
|
-
this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
|
|
1337
|
+
this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : /* istanbul ignore next */ []));
|
|
1111
1338
|
/**
|
|
1112
1339
|
* The shape of our form model. This is a deep required version of the form model
|
|
1113
1340
|
* The goal is to add default values to the shape so when the template-driven form
|
|
1114
1341
|
* contains values that shouldn't be there (typo's) that the developer gets run-time
|
|
1115
1342
|
* errors in dev mode
|
|
1116
1343
|
*/
|
|
1117
|
-
this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : []));
|
|
1344
|
+
this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : /* istanbul ignore next */ []));
|
|
1118
1345
|
/**
|
|
1119
1346
|
* Updates the validation config which is a dynamic object that will be used to
|
|
1120
1347
|
* trigger validations on the dependant fields
|
|
@@ -1128,7 +1355,7 @@ class FormDirective {
|
|
|
1128
1355
|
*
|
|
1129
1356
|
* @param v
|
|
1130
1357
|
*/
|
|
1131
|
-
this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : []));
|
|
1358
|
+
this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : /* istanbul ignore next */ []));
|
|
1132
1359
|
/**
|
|
1133
1360
|
* Emits whenever validation feedback may have changed, even if the aggregate
|
|
1134
1361
|
* root form status string stays the same.
|
|
@@ -1159,7 +1386,7 @@ class FormDirective {
|
|
|
1159
1386
|
*/
|
|
1160
1387
|
this.formValueChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof ValueChangeEvent), map((v) => v.value), distinctUntilChanged((prev, curr) => {
|
|
1161
1388
|
// Use efficient deep equality instead of JSON.stringify for better performance
|
|
1162
|
-
return
|
|
1389
|
+
return this.#equal(prev, curr);
|
|
1163
1390
|
}), map(() => mergeValuesAndRawValues(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
|
|
1164
1391
|
/**
|
|
1165
1392
|
* Emits an object with all the errors of the form
|
|
@@ -1187,11 +1414,18 @@ class FormDirective {
|
|
|
1187
1414
|
* Cleanup is handled automatically by the directive when it's destroyed.
|
|
1188
1415
|
*/
|
|
1189
1416
|
this.validChange = outputFromObservable(this.statusChanges$.pipe(filter((e) => e === 'VALID' || e === 'INVALID'), map((v) => v === 'VALID'), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
|
|
1417
|
+
/**
|
|
1418
|
+
* Emits when a named control inside the form loses focus.
|
|
1419
|
+
*
|
|
1420
|
+
* Useful for application-level workflows such as draft auto-save on blur.
|
|
1421
|
+
*/
|
|
1422
|
+
this.fieldBlur = output();
|
|
1190
1423
|
/**
|
|
1191
1424
|
* Track validation in progress to prevent circular triggering (Issue #19)
|
|
1192
1425
|
*/
|
|
1193
1426
|
this.validationInProgress = new Set();
|
|
1194
1427
|
this.destroyRef.onDestroy(() => {
|
|
1428
|
+
this.#destroyed = true;
|
|
1195
1429
|
this.fieldWarnings.set(new Map());
|
|
1196
1430
|
});
|
|
1197
1431
|
/**
|
|
@@ -1227,8 +1461,8 @@ class FormDirective {
|
|
|
1227
1461
|
if (!formValue && !modelValue)
|
|
1228
1462
|
return;
|
|
1229
1463
|
// Compute change flags first
|
|
1230
|
-
const formChanged = !
|
|
1231
|
-
const modelChanged = !
|
|
1464
|
+
const formChanged = !this.#equal(formValue, this.#lastSyncedFormValue);
|
|
1465
|
+
const modelChanged = !this.#equal(modelValue, this.#lastSyncedModelValue);
|
|
1232
1466
|
// Early return if nothing changed
|
|
1233
1467
|
if (!formChanged && !modelChanged) {
|
|
1234
1468
|
return;
|
|
@@ -1262,7 +1496,7 @@ class FormDirective {
|
|
|
1262
1496
|
else if (formChanged && modelChanged) {
|
|
1263
1497
|
// Both form and model changed simultaneously
|
|
1264
1498
|
// Check if they changed to the same value (synchronized change) or different values (conflict)
|
|
1265
|
-
const valuesEqual =
|
|
1499
|
+
const valuesEqual = this.#equal(formValue, modelValue);
|
|
1266
1500
|
if (valuesEqual) {
|
|
1267
1501
|
// Both changed to the same value - this is a synchronized change, not a conflict
|
|
1268
1502
|
// Just update tracking to acknowledge the change
|
|
@@ -1494,13 +1728,98 @@ class FormDirective {
|
|
|
1494
1728
|
* Host handler: called whenever any descendant field loses focus.
|
|
1495
1729
|
* Used to make touched-path tracking react immediately on blur/tab.
|
|
1496
1730
|
*/
|
|
1497
|
-
onFormFocusOut() {
|
|
1731
|
+
onFormFocusOut(event) {
|
|
1498
1732
|
// Run on the next microtask to ensure Angular has already applied
|
|
1499
1733
|
// control.touched changes for the field that just blurred.
|
|
1500
|
-
|
|
1734
|
+
scheduleMicrotask(() => {
|
|
1501
1735
|
this.#blurTick.update((v) => v + 1);
|
|
1736
|
+
this.#emitFieldBlurEvent(event);
|
|
1737
|
+
}, this.destroyRef);
|
|
1738
|
+
}
|
|
1739
|
+
#emitFieldBlurEvent(event) {
|
|
1740
|
+
const resolved = this.#resolveFieldFromFocusEvent(event);
|
|
1741
|
+
if (!resolved) {
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
const { field, control, element } = resolved;
|
|
1745
|
+
// Read the latest value directly from the DOM element when it can be
|
|
1746
|
+
// trusted. For radio groups Angular keeps the bound `control.value` in
|
|
1747
|
+
// sync with the *selected* option, while a focused-but-unchecked radio
|
|
1748
|
+
// would expose its own option value via the DOM — so radios must always
|
|
1749
|
+
// fall back to `control.value`. For text/textarea/select we prefer the
|
|
1750
|
+
// element value to avoid `ngModelOptions.updateOn: 'submit'` staleness.
|
|
1751
|
+
const domValue = readElementValueForBlur(element);
|
|
1752
|
+
const value = domValue !== undefined ? domValue : control.value;
|
|
1753
|
+
// Prefer the cached linked-signal snapshot when it exists. Both code
|
|
1754
|
+
// paths produce a deep-cloned snapshot, but reusing the cached value
|
|
1755
|
+
// saves one of the two `structuredClone` passes performed by
|
|
1756
|
+
// `mergeValuesAndRawValues()` on every blur.
|
|
1757
|
+
const cachedSnapshot = this.#formValueSignal();
|
|
1758
|
+
const formValue = cachedSnapshot !== null
|
|
1759
|
+
? structuredClone(cachedSnapshot)
|
|
1760
|
+
: mergeValuesAndRawValues(this.ngForm.form);
|
|
1761
|
+
setValueAtPath(formValue, field, value);
|
|
1762
|
+
this.fieldBlur.emit({
|
|
1763
|
+
field,
|
|
1764
|
+
value,
|
|
1765
|
+
formValue,
|
|
1766
|
+
dirty: control.dirty,
|
|
1767
|
+
touched: control.touched,
|
|
1768
|
+
valid: control.valid,
|
|
1769
|
+
pending: control.pending,
|
|
1502
1770
|
});
|
|
1503
1771
|
}
|
|
1772
|
+
#resolveFieldFromFocusEvent(event) {
|
|
1773
|
+
const target = event.target;
|
|
1774
|
+
if (!(target instanceof Element)) {
|
|
1775
|
+
return null;
|
|
1776
|
+
}
|
|
1777
|
+
const fieldElement = target.closest('[name]');
|
|
1778
|
+
if (!(fieldElement instanceof HTMLElement)) {
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1781
|
+
const name = fieldElement.getAttribute('name')?.trim();
|
|
1782
|
+
if (!name) {
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
const formEl = this.elementRef.nativeElement;
|
|
1786
|
+
// Authoritative path: ask the registered NgModel directive whose value
|
|
1787
|
+
// accessor is bound to this exact element. This handles all forms of
|
|
1788
|
+
// grouping uniformly — static `ngModelGroup="key"`, dynamic
|
|
1789
|
+
// `[ngModelGroup]="expr"`, repeated leaf names across siblings — because
|
|
1790
|
+
// the directive's `path` is computed from the live ControlContainer tree.
|
|
1791
|
+
const directiveMatch = resolveControlPathByNgModelDirective(this.ngForm, fieldElement);
|
|
1792
|
+
if (directiveMatch) {
|
|
1793
|
+
return {
|
|
1794
|
+
field: directiveMatch.path,
|
|
1795
|
+
control: directiveMatch.control,
|
|
1796
|
+
element: fieldElement,
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
// Fallback: walk DOM ancestors collecting any `ngModelGroup` attribute
|
|
1800
|
+
// values, producing the canonical dotted path for the static attribute
|
|
1801
|
+
// form (e.g. `<div ngModelGroup="passwords"><input name="password">` →
|
|
1802
|
+
// `passwords.password`). Used when the directive lookup misses (e.g. the
|
|
1803
|
+
// value accessor doesn't expose its element ref in some custom CVAs).
|
|
1804
|
+
const staticGroups = collectNgModelGroupAttributes(fieldElement, formEl);
|
|
1805
|
+
const staticPath = [...staticGroups, name].join('.');
|
|
1806
|
+
const staticControl = this.ngForm.form.get(staticPath);
|
|
1807
|
+
if (staticControl) {
|
|
1808
|
+
return { field: staticPath, control: staticControl, element: fieldElement };
|
|
1809
|
+
}
|
|
1810
|
+
// Last-resort fallback for ambiguous DOM structures: probe each ancestor
|
|
1811
|
+
// element as a potential group boundary, querying the form tree until we
|
|
1812
|
+
// find a child that owns this DOM element.
|
|
1813
|
+
const dynamicMatch = resolveControlPathByDomAncestors(this.ngForm.form, fieldElement, formEl, name);
|
|
1814
|
+
if (dynamicMatch) {
|
|
1815
|
+
return {
|
|
1816
|
+
field: dynamicMatch.path,
|
|
1817
|
+
control: dynamicMatch.control,
|
|
1818
|
+
element: fieldElement,
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1504
1823
|
/**
|
|
1505
1824
|
* Resets the form to a pristine, untouched state with optional new values.
|
|
1506
1825
|
*
|
|
@@ -1561,7 +1880,6 @@ class FormDirective {
|
|
|
1561
1880
|
// is treated as a model change (not a conflict with stale form values)
|
|
1562
1881
|
this.#lastSyncedFormValue = null;
|
|
1563
1882
|
this.#lastSyncedModelValue = null;
|
|
1564
|
-
this.#lastLinkedValue = null;
|
|
1565
1883
|
// Force change detection to ensure DOM updates are reflected
|
|
1566
1884
|
// Note: This is still needed even with signals because we're modifying NgForm
|
|
1567
1885
|
// (reactive forms), not signals. The formValue signal updates happen in the
|
|
@@ -1602,6 +1920,18 @@ class FormDirective {
|
|
|
1602
1920
|
// Both NgxVestSuite and NgxTypedVestSuite work with string at runtime
|
|
1603
1921
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1604
1922
|
suite(snap, field).done((result) => {
|
|
1923
|
+
// Guard: bail out if the directive was destroyed while
|
|
1924
|
+
// validation was in flight to avoid writing to disposed
|
|
1925
|
+
// signals or a torn-down view.
|
|
1926
|
+
if (this.#destroyed) {
|
|
1927
|
+
// Emit a neutral `null` before completing so async
|
|
1928
|
+
// validators always emit exactly once. Completing without
|
|
1929
|
+
// emission can leave consumers (e.g. a control's status)
|
|
1930
|
+
// in an unexpected `PENDING` state.
|
|
1931
|
+
observer.next(null);
|
|
1932
|
+
observer.complete();
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1605
1935
|
const errors = result.getErrors()[field];
|
|
1606
1936
|
const warnings = result.getWarnings()[field];
|
|
1607
1937
|
// Store warnings in the fieldWarnings signal for access by control wrappers.
|
|
@@ -1638,8 +1968,9 @@ class FormDirective {
|
|
|
1638
1968
|
// visual state (even though the control status has updated).
|
|
1639
1969
|
//
|
|
1640
1970
|
// We schedule a detectChanges() on the next microtask to avoid calling it
|
|
1641
|
-
// synchronously inside Angular's own validation pipeline.
|
|
1642
|
-
|
|
1971
|
+
// synchronously inside Angular's own validation pipeline. The scheduleMicrotask
|
|
1972
|
+
// primitive auto-cancels if the directive is destroyed before it fires.
|
|
1973
|
+
scheduleMicrotask(() => {
|
|
1643
1974
|
try {
|
|
1644
1975
|
this.cdr.detectChanges();
|
|
1645
1976
|
}
|
|
@@ -1648,7 +1979,7 @@ class FormDirective {
|
|
|
1648
1979
|
// This keeps behavior resilient in edge cases.
|
|
1649
1980
|
this.cdr.markForCheck();
|
|
1650
1981
|
}
|
|
1651
|
-
});
|
|
1982
|
+
}, this.destroyRef);
|
|
1652
1983
|
observer.next(out);
|
|
1653
1984
|
observer.complete();
|
|
1654
1985
|
});
|
|
@@ -1687,10 +2018,7 @@ class FormDirective {
|
|
|
1687
2018
|
this.validationInProgress.clear();
|
|
1688
2019
|
return EMPTY;
|
|
1689
2020
|
}
|
|
1690
|
-
const streams = Object.
|
|
1691
|
-
const dependents = config[triggerField] || [];
|
|
1692
|
-
return this.#createTriggerStream(form, triggerField, dependents);
|
|
1693
|
-
});
|
|
2021
|
+
const streams = Object.entries(config).map(([triggerField, dependents]) => this.#createTriggerStream(form, triggerField, dependents || []));
|
|
1694
2022
|
return streams.length > 0 ? merge(...streams) : EMPTY;
|
|
1695
2023
|
}
|
|
1696
2024
|
/**
|
|
@@ -1805,20 +2133,6 @@ class FormDirective {
|
|
|
1805
2133
|
// CRITICAL: Mark the dependent field as in-progress BEFORE calling updateValueAndValidity
|
|
1806
2134
|
// This prevents the dependent field's valueChanges from triggering its own validationConfig
|
|
1807
2135
|
this.validationInProgress.add(depField);
|
|
1808
|
-
// NOTE: Touch propagation removed (PR #78)
|
|
1809
|
-
// Previously, we propagated touch state from trigger to dependent fields.
|
|
1810
|
-
// This caused UX issues where dependent fields showed errors immediately
|
|
1811
|
-
// after being revealed by a toggle, even though the user never interacted with them.
|
|
1812
|
-
//
|
|
1813
|
-
// With this change:
|
|
1814
|
-
// - Errors on dependent fields only show after the user directly touches/blurs them
|
|
1815
|
-
// - ARIA attributes (aria-invalid) still work correctly via isInvalid check
|
|
1816
|
-
// - Warnings still show after validation via hasBeenValidated check
|
|
1817
|
-
//
|
|
1818
|
-
// The removed code was:
|
|
1819
|
-
// if (control.touched && !dependentControl.touched) {
|
|
1820
|
-
// dependentControl.markAsTouched({ onlySelf: true });
|
|
1821
|
-
// }
|
|
1822
2136
|
// emitEvent: true is REQUIRED for async validators to actually run
|
|
1823
2137
|
// The validationInProgress Set prevents infinite loops:
|
|
1824
2138
|
// 1. Field A changes → triggers validation on dependent field B
|
|
@@ -1842,13 +2156,14 @@ class FormDirective {
|
|
|
1842
2156
|
}
|
|
1843
2157
|
}
|
|
1844
2158
|
// Keep fields marked as in-progress for a short time to prevent immediate re-triggering
|
|
1845
|
-
// Use
|
|
1846
|
-
|
|
2159
|
+
// Use scheduleTimeout to ensure async validators have time to complete before allowing
|
|
2160
|
+
// new triggers. The timer auto-cancels on directive destroy so no timers leak.
|
|
2161
|
+
scheduleTimeout(() => {
|
|
1847
2162
|
this.validationInProgress.delete(triggerField);
|
|
1848
2163
|
for (const depField of dependents) {
|
|
1849
2164
|
this.validationInProgress.delete(depField);
|
|
1850
2165
|
}
|
|
1851
|
-
}, VALIDATION_IN_PROGRESS_TIMEOUT_MS);
|
|
2166
|
+
}, VALIDATION_IN_PROGRESS_TIMEOUT_MS, this.destroyRef);
|
|
1852
2167
|
}
|
|
1853
2168
|
/**
|
|
1854
2169
|
* Collects field paths of all touched (or submitted) leaf controls
|
|
@@ -1876,19 +2191,180 @@ class FormDirective {
|
|
|
1876
2191
|
collect(control, []);
|
|
1877
2192
|
return fields;
|
|
1878
2193
|
}
|
|
1879
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
1880
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.
|
|
2194
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2195
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: FormDirective, isStandalone: true, selector: "form[scVestForm], form[ngxVestForm]", inputs: { formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, formShape: { classPropertyName: "formShape", publicName: "formShape", isSignal: true, isRequired: false, transformFunction: null }, validationConfig: { classPropertyName: "validationConfig", publicName: "validationConfig", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formValueChange: "formValueChange", errorsChange: "errorsChange", dirtyChange: "dirtyChange", validChange: "validChange", fieldBlur: "fieldBlur" }, host: { listeners: { "focusout": "onFormFocusOut($event)" } }, exportAs: ["scVestForm", "ngxVestForm"], ngImport: i0 }); }
|
|
1881
2196
|
}
|
|
1882
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
2197
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormDirective, decorators: [{
|
|
1883
2198
|
type: Directive,
|
|
1884
2199
|
args: [{
|
|
1885
2200
|
selector: 'form[scVestForm], form[ngxVestForm]',
|
|
1886
2201
|
exportAs: 'scVestForm, ngxVestForm',
|
|
1887
2202
|
host: {
|
|
1888
|
-
'(focusout)': 'onFormFocusOut()',
|
|
2203
|
+
'(focusout)': 'onFormFocusOut($event)',
|
|
1889
2204
|
},
|
|
1890
2205
|
}]
|
|
1891
|
-
}], ctorParameters: () => [], propDecorators: { formValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "formValue", required: false }] }], suite: [{ type: i0.Input, args: [{ isSignal: true, alias: "suite", required: false }] }], formShape: [{ type: i0.Input, args: [{ isSignal: true, alias: "formShape", required: false }] }], validationConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationConfig", required: false }] }], formValueChange: [{ type: i0.Output, args: ["formValueChange"] }], errorsChange: [{ type: i0.Output, args: ["errorsChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], validChange: [{ type: i0.Output, args: ["validChange"] }] } });
|
|
2206
|
+
}], ctorParameters: () => [], propDecorators: { formValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "formValue", required: false }] }], suite: [{ type: i0.Input, args: [{ isSignal: true, alias: "suite", required: false }] }], formShape: [{ type: i0.Input, args: [{ isSignal: true, alias: "formShape", required: false }] }], validationConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationConfig", required: false }] }], formValueChange: [{ type: i0.Output, args: ["formValueChange"] }], errorsChange: [{ type: i0.Output, args: ["errorsChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], validChange: [{ type: i0.Output, args: ["validChange"] }], fieldBlur: [{ type: i0.Output, args: ["fieldBlur"] }] } });
|
|
2207
|
+
/**
|
|
2208
|
+
* Reads the user-entered value from a blurred form element. Returns
|
|
2209
|
+
* `undefined` for radio inputs (caller must fall back to the bound
|
|
2210
|
+
* `control.value`, since the focused radio is not necessarily the
|
|
2211
|
+
* group's selected option) and for elements we don't handle.
|
|
2212
|
+
*/
|
|
2213
|
+
function readElementValueForBlur(element) {
|
|
2214
|
+
if (element instanceof HTMLInputElement) {
|
|
2215
|
+
if (element.type === 'radio')
|
|
2216
|
+
return undefined;
|
|
2217
|
+
if (element.type === 'checkbox')
|
|
2218
|
+
return element.checked;
|
|
2219
|
+
if (element.type === 'number') {
|
|
2220
|
+
return element.value === '' ? null : element.valueAsNumber;
|
|
2221
|
+
}
|
|
2222
|
+
return element.value;
|
|
2223
|
+
}
|
|
2224
|
+
if (element instanceof HTMLTextAreaElement ||
|
|
2225
|
+
element instanceof HTMLSelectElement) {
|
|
2226
|
+
return element.value;
|
|
2227
|
+
}
|
|
2228
|
+
return undefined;
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Walks DOM ancestors between `start` (exclusive) and `formEl` (exclusive),
|
|
2232
|
+
* collecting any preserved `ngModelGroup` attribute values into a path
|
|
2233
|
+
* suitable for `FormGroup.get()`. Only the static attribute form is
|
|
2234
|
+
* preserved on the DOM; dynamically-bound `[ngModelGroup]` is handled by
|
|
2235
|
+
* `resolveControlPathByDomAncestors`.
|
|
2236
|
+
*/
|
|
2237
|
+
function collectNgModelGroupAttributes(start, formEl) {
|
|
2238
|
+
const groups = [];
|
|
2239
|
+
let current = start.parentElement;
|
|
2240
|
+
while (current && current !== formEl && formEl.contains(current)) {
|
|
2241
|
+
const groupName = current.getAttribute('ngModelGroup')?.trim();
|
|
2242
|
+
if (groupName) {
|
|
2243
|
+
groups.unshift(groupName);
|
|
2244
|
+
}
|
|
2245
|
+
current = current.parentElement;
|
|
2246
|
+
}
|
|
2247
|
+
return groups;
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Resolves the dotted control path for a blurred element when the static
|
|
2251
|
+
* `ngModelGroup` attribute walk failed (typically because the host used
|
|
2252
|
+
* `[ngModelGroup]="expr"`, which Angular does not always preserve as a
|
|
2253
|
+
* DOM attribute). Walks the control tree top-down and at each FormGroup
|
|
2254
|
+
* boundary tries to descend into a child whose subtree contains an element
|
|
2255
|
+
* with the matching `name` — disambiguating repeated leaf names by DOM
|
|
2256
|
+
* containment instead of giving up.
|
|
2257
|
+
*/
|
|
2258
|
+
function resolveControlPathByDomAncestors(root, fieldElement, formEl, leafName) {
|
|
2259
|
+
const descend = (frame) => {
|
|
2260
|
+
if (frame.control instanceof FormGroup) {
|
|
2261
|
+
for (const [key, child] of Object.entries(frame.control.controls)) {
|
|
2262
|
+
if (key === leafName && !(child instanceof FormGroup)) {
|
|
2263
|
+
return { control: child, path: [...frame.path, key] };
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
const candidates = [];
|
|
2267
|
+
for (const [key, child] of Object.entries(frame.control.controls)) {
|
|
2268
|
+
if (child instanceof FormGroup || child instanceof FormArray) {
|
|
2269
|
+
if (subtreeContainsElement(child, fieldElement, formEl, key)) {
|
|
2270
|
+
candidates.push({ control: child, path: [...frame.path, key] });
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
if (candidates.length === 1 && candidates[0])
|
|
2275
|
+
return descend(candidates[0]);
|
|
2276
|
+
return null;
|
|
2277
|
+
}
|
|
2278
|
+
if (frame.control instanceof FormArray) {
|
|
2279
|
+
const candidates = [];
|
|
2280
|
+
frame.control.controls.forEach((child, index) => {
|
|
2281
|
+
if (child instanceof FormGroup || child instanceof FormArray) {
|
|
2282
|
+
if (subtreeContainsElement(child, fieldElement, formEl, index)) {
|
|
2283
|
+
candidates.push({ control: child, path: [...frame.path, index] });
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
if (candidates.length === 1 && candidates[0])
|
|
2288
|
+
return descend(candidates[0]);
|
|
2289
|
+
}
|
|
2290
|
+
return null;
|
|
2291
|
+
};
|
|
2292
|
+
const result = descend({ control: root, path: [] });
|
|
2293
|
+
if (!result)
|
|
2294
|
+
return null;
|
|
2295
|
+
return { path: stringifyFieldPath(result.path), control: result.control };
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Best-effort check: does the DOM subtree rooted at any element annotated
|
|
2299
|
+
* with `ngModelGroup="<key>"` contain `fieldElement`? Used by
|
|
2300
|
+
* `resolveControlPathByDomAncestors` to disambiguate repeated leaf names.
|
|
2301
|
+
* For dynamic `[ngModelGroup]` with no preserved attribute we cannot
|
|
2302
|
+
* disambiguate from DOM alone — those cases return `false`, matching the
|
|
2303
|
+
* documented limitation.
|
|
2304
|
+
*/
|
|
2305
|
+
function subtreeContainsElement(_child, fieldElement, formEl, key) {
|
|
2306
|
+
if (typeof key !== 'string')
|
|
2307
|
+
return false;
|
|
2308
|
+
const selector = `[ngModelGroup="${CSS.escape(key)}"]`;
|
|
2309
|
+
const candidates = formEl.querySelectorAll(selector);
|
|
2310
|
+
for (const candidate of candidates) {
|
|
2311
|
+
if (candidate.contains(fieldElement))
|
|
2312
|
+
return true;
|
|
2313
|
+
}
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Resolves the control + dotted path for a blurred element by consulting the
|
|
2318
|
+
* `NgModel` directives Angular registered with this `NgForm`. Each registered
|
|
2319
|
+
* directive carries a live `path` (the full ControlContainer chain) and a
|
|
2320
|
+
* value accessor whose element ref is the input the directive is hosted on.
|
|
2321
|
+
*
|
|
2322
|
+
* This handles all grouping shapes uniformly — static `ngModelGroup="key"`,
|
|
2323
|
+
* dynamic `[ngModelGroup]="expr"`, and repeated leaf names across siblings —
|
|
2324
|
+
* because the path comes from the live form tree rather than DOM heuristics.
|
|
2325
|
+
* Returns `null` when the directive can't be matched (e.g. a custom CVA that
|
|
2326
|
+
* doesn't store an element ref), so the caller can fall back to DOM probes.
|
|
2327
|
+
*/
|
|
2328
|
+
function resolveControlPathByNgModelDirective(ngForm, fieldElement) {
|
|
2329
|
+
const directives = readNgFormDirectives(ngForm);
|
|
2330
|
+
if (!directives)
|
|
2331
|
+
return null;
|
|
2332
|
+
for (const directive of directives) {
|
|
2333
|
+
const accessorEl = readValueAccessorElement(directive.valueAccessor);
|
|
2334
|
+
if (accessorEl !== fieldElement)
|
|
2335
|
+
continue;
|
|
2336
|
+
const control = directive.control ?? ngForm.form.get(directive.path);
|
|
2337
|
+
if (!control)
|
|
2338
|
+
return null;
|
|
2339
|
+
return { path: directive.path.join('.'), control };
|
|
2340
|
+
}
|
|
2341
|
+
return null;
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Reads the registered `NgModel` directives from `NgForm`. Angular forms keeps
|
|
2345
|
+
* them in a private `_directives: Set<NgModel>`. The field name is stable
|
|
2346
|
+
* across all Angular versions that ship `ngModel`, but is not part of the
|
|
2347
|
+
* public type — callers must tolerate `null`.
|
|
2348
|
+
*/
|
|
2349
|
+
function readNgFormDirectives(ngForm) {
|
|
2350
|
+
const set = ngForm._directives;
|
|
2351
|
+
return set ?? null;
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Reads the host element of a `ControlValueAccessor`. The standard accessors
|
|
2355
|
+
* shipped by Angular forms (default, number, select, radio, checkbox, range)
|
|
2356
|
+
* all store an `ElementRef` injected at construction as `_elementRef`. This
|
|
2357
|
+
* is private but stable; custom accessors that don't follow the convention
|
|
2358
|
+
* will simply miss the fast path.
|
|
2359
|
+
*/
|
|
2360
|
+
function readValueAccessorElement(accessor) {
|
|
2361
|
+
if (!accessor)
|
|
2362
|
+
return null;
|
|
2363
|
+
const elementRef = accessor
|
|
2364
|
+
._elementRef;
|
|
2365
|
+
const native = elementRef?.nativeElement;
|
|
2366
|
+
return native instanceof HTMLElement ? native : null;
|
|
2367
|
+
}
|
|
1892
2368
|
|
|
1893
2369
|
const INITIAL_FORM_CONTROL_STATE = {
|
|
1894
2370
|
status: 'INVALID',
|
|
@@ -1929,9 +2405,22 @@ class FormControlStateDirective {
|
|
|
1929
2405
|
* Internal signal for control state (updated reactively)
|
|
1930
2406
|
*/
|
|
1931
2407
|
#controlStateSignal;
|
|
2408
|
+
/**
|
|
2409
|
+
* Tick bumped once via `afterNextRender` if `control.control` is undefined
|
|
2410
|
+
* at the first effect run (NgModel registers asynchronously). Reading this
|
|
2411
|
+
* signal inside the effect causes a re-evaluation after the next render so
|
|
2412
|
+
* we pick up the late-attached `FormControl`. Bookkeeping below ensures we
|
|
2413
|
+
* only schedule a single retry — no permanent polling.
|
|
2414
|
+
*/
|
|
2415
|
+
#controlAttachTick;
|
|
2416
|
+
// Latch is scoped to the *current* `#activeControl` instance. When the
|
|
2417
|
+
// active control changes (e.g. host swaps an `@if` block, NgModel
|
|
2418
|
+
// recreates), the latch resets so a fresh late-attach gets one retry.
|
|
2419
|
+
#controlAttachRetryScheduled;
|
|
2420
|
+
#lastSeenActiveControl;
|
|
1932
2421
|
constructor() {
|
|
1933
|
-
this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
|
|
1934
|
-
this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : []));
|
|
2422
|
+
this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : /* istanbul ignore next */ []));
|
|
2423
|
+
this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : /* istanbul ignore next */ []));
|
|
1935
2424
|
this.#hostNgModel = inject(NgModel, { self: true, optional: true });
|
|
1936
2425
|
this.#hostNgModelGroup = inject(NgModelGroup, {
|
|
1937
2426
|
self: true,
|
|
@@ -1950,7 +2439,7 @@ class FormControlStateDirective {
|
|
|
1950
2439
|
this.#hostNgModelGroup ||
|
|
1951
2440
|
this.contentNgModel() ||
|
|
1952
2441
|
this.contentNgModelGroup() ||
|
|
1953
|
-
null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
|
|
2442
|
+
null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : /* istanbul ignore next */ []));
|
|
1954
2443
|
/**
|
|
1955
2444
|
* Consolidated internal signal for interaction state tracking.
|
|
1956
2445
|
* Combines touched, dirty, and hasBeenValidated into a single signal
|
|
@@ -1960,7 +2449,7 @@ class FormControlStateDirective {
|
|
|
1960
2449
|
isTouched: false,
|
|
1961
2450
|
isDirty: false,
|
|
1962
2451
|
hasBeenValidated: false,
|
|
1963
|
-
}, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
|
|
2452
|
+
}, ...(ngDevMode ? [{ debugName: "#interactionState" }] : /* istanbul ignore next */ []));
|
|
1964
2453
|
/**
|
|
1965
2454
|
* Track the previous status to detect actual status changes (not just status emissions).
|
|
1966
2455
|
* This helps distinguish between initial control creation and actual re-validation.
|
|
@@ -1969,11 +2458,24 @@ class FormControlStateDirective {
|
|
|
1969
2458
|
/**
|
|
1970
2459
|
* Internal signal for control state (updated reactively)
|
|
1971
2460
|
*/
|
|
1972
|
-
this.#controlStateSignal = signal(INITIAL_FORM_CONTROL_STATE, ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : []));
|
|
2461
|
+
this.#controlStateSignal = signal(INITIAL_FORM_CONTROL_STATE, ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : /* istanbul ignore next */ []));
|
|
2462
|
+
/**
|
|
2463
|
+
* Tick bumped once via `afterNextRender` if `control.control` is undefined
|
|
2464
|
+
* at the first effect run (NgModel registers asynchronously). Reading this
|
|
2465
|
+
* signal inside the effect causes a re-evaluation after the next render so
|
|
2466
|
+
* we pick up the late-attached `FormControl`. Bookkeeping below ensures we
|
|
2467
|
+
* only schedule a single retry — no permanent polling.
|
|
2468
|
+
*/
|
|
2469
|
+
this.#controlAttachTick = signal(0, ...(ngDevMode ? [{ debugName: "#controlAttachTick" }] : /* istanbul ignore next */ []));
|
|
2470
|
+
// Latch is scoped to the *current* `#activeControl` instance. When the
|
|
2471
|
+
// active control changes (e.g. host swaps an `@if` block, NgModel
|
|
2472
|
+
// recreates), the latch resets so a fresh late-attach gets one retry.
|
|
2473
|
+
this.#controlAttachRetryScheduled = false;
|
|
2474
|
+
this.#lastSeenActiveControl = null;
|
|
1973
2475
|
/**
|
|
1974
2476
|
* Main control state computed signal (merges robust touched/dirty)
|
|
1975
2477
|
*/
|
|
1976
|
-
this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
|
|
2478
|
+
this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : /* istanbul ignore next */ []));
|
|
1977
2479
|
/**
|
|
1978
2480
|
* Extracts error messages from Angular/Vest errors (recursively flattens)
|
|
1979
2481
|
*/
|
|
@@ -1993,20 +2495,20 @@ class FormControlStateDirective {
|
|
|
1993
2495
|
const errorsWithoutWarnings = { ...errors };
|
|
1994
2496
|
delete errorsWithoutWarnings['warnings'];
|
|
1995
2497
|
return this.#flattenAngularErrors(errorsWithoutWarnings);
|
|
1996
|
-
}, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
|
|
2498
|
+
}, ...(ngDevMode ? [{ debugName: "errorMessages" }] : /* istanbul ignore next */ []));
|
|
1997
2499
|
/**
|
|
1998
2500
|
* ADVANCED: updateOn strategy (change/blur/submit) if available
|
|
1999
2501
|
*/
|
|
2000
2502
|
this.updateOn = computed(() => {
|
|
2001
2503
|
const ngModel = this.contentNgModel() || this.#hostNgModel;
|
|
2002
2504
|
return ngModel?.options?.updateOn ?? 'change';
|
|
2003
|
-
}, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
|
|
2505
|
+
}, ...(ngDevMode ? [{ debugName: "updateOn" }] : /* istanbul ignore next */ []));
|
|
2004
2506
|
/**
|
|
2005
2507
|
* ADVANCED: Composite/derived signals for advanced error display logic
|
|
2006
2508
|
*/
|
|
2007
|
-
this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
|
|
2008
|
-
this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
|
|
2009
|
-
this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
|
|
2509
|
+
this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : /* istanbul ignore next */ []));
|
|
2510
|
+
this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : /* istanbul ignore next */ []));
|
|
2511
|
+
this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : /* istanbul ignore next */ []));
|
|
2010
2512
|
/**
|
|
2011
2513
|
* Extracts warning messages from Vest validation results.
|
|
2012
2514
|
* Checks two sources:
|
|
@@ -2041,36 +2543,59 @@ class FormControlStateDirective {
|
|
|
2041
2543
|
}
|
|
2042
2544
|
}
|
|
2043
2545
|
return [];
|
|
2044
|
-
}, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
|
|
2546
|
+
}, ...(ngDevMode ? [{ debugName: "warningMessages" }] : /* istanbul ignore next */ []));
|
|
2045
2547
|
/**
|
|
2046
2548
|
* Whether async validation is in progress
|
|
2047
2549
|
*/
|
|
2048
|
-
this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
|
|
2550
|
+
this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : /* istanbul ignore next */ []));
|
|
2049
2551
|
/**
|
|
2050
2552
|
* Convenience signals for common state checks
|
|
2051
2553
|
*/
|
|
2052
|
-
this.isValid = computed(() => this.controlState().isValid, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
|
|
2053
|
-
this.isInvalid = computed(() => this.controlState().isInvalid, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
|
|
2054
|
-
this.isPending = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
2055
|
-
this.isTouched = computed(() => this.controlState().isTouched, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
|
|
2056
|
-
this.isDirty = computed(() => this.controlState().isDirty, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
|
|
2057
|
-
this.isPristine = computed(() => this.controlState().isPristine, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
|
|
2058
|
-
this.isDisabled = computed(() => this.controlState().isDisabled, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
|
|
2059
|
-
this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : []));
|
|
2554
|
+
this.isValid = computed(() => this.controlState().isValid, ...(ngDevMode ? [{ debugName: "isValid" }] : /* istanbul ignore next */ []));
|
|
2555
|
+
this.isInvalid = computed(() => this.controlState().isInvalid, ...(ngDevMode ? [{ debugName: "isInvalid" }] : /* istanbul ignore next */ []));
|
|
2556
|
+
this.isPending = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "isPending" }] : /* istanbul ignore next */ []));
|
|
2557
|
+
this.isTouched = computed(() => this.controlState().isTouched, ...(ngDevMode ? [{ debugName: "isTouched" }] : /* istanbul ignore next */ []));
|
|
2558
|
+
this.isDirty = computed(() => this.controlState().isDirty, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
|
|
2559
|
+
this.isPristine = computed(() => this.controlState().isPristine, ...(ngDevMode ? [{ debugName: "isPristine" }] : /* istanbul ignore next */ []));
|
|
2560
|
+
this.isDisabled = computed(() => this.controlState().isDisabled, ...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
|
|
2561
|
+
this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : /* istanbul ignore next */ []));
|
|
2060
2562
|
/**
|
|
2061
2563
|
* Whether this control has been validated at least once.
|
|
2062
2564
|
* True after the first validation completes, even if the user hasn't touched the field.
|
|
2063
|
-
* This
|
|
2565
|
+
* This is primarily used for warning display and other derived state that should react
|
|
2566
|
+
* to validationConfig-triggered validation even before the user touches the field.
|
|
2064
2567
|
*/
|
|
2065
|
-
this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : []));
|
|
2568
|
+
this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : /* istanbul ignore next */ []));
|
|
2066
2569
|
// Update control state reactively with proper cleanup
|
|
2067
2570
|
effect((onCleanup) => {
|
|
2068
2571
|
const control = this.#activeControl();
|
|
2069
2572
|
const interaction = this.#interactionState();
|
|
2573
|
+
// Track the retry tick so a late-attached `control.control` re-runs this
|
|
2574
|
+
// effect after the next render.
|
|
2575
|
+
this.#controlAttachTick();
|
|
2576
|
+
// Re-arm the late-attach latch whenever the active control identity
|
|
2577
|
+
// changes — including transitions to/from null — so a newly mounted
|
|
2578
|
+
// directive whose `FormControl` registers asynchronously gets its own
|
|
2579
|
+
// single retry.
|
|
2580
|
+
if (control !== this.#lastSeenActiveControl) {
|
|
2581
|
+
this.#lastSeenActiveControl = control;
|
|
2582
|
+
this.#controlAttachRetryScheduled = false;
|
|
2583
|
+
}
|
|
2070
2584
|
if (!control) {
|
|
2071
2585
|
this.#controlStateSignal.set(INITIAL_FORM_CONTROL_STATE);
|
|
2072
2586
|
return;
|
|
2073
2587
|
}
|
|
2588
|
+
// NgModel attaches its `FormControl` during its own ngOnInit, which can
|
|
2589
|
+
// run after this effect's first execution in some host orderings. If
|
|
2590
|
+
// `control.control` isn't there yet, schedule a single `afterNextRender`
|
|
2591
|
+
// retry so we re-evaluate once Angular finishes wiring directives.
|
|
2592
|
+
if (!control.control && !this.#controlAttachRetryScheduled) {
|
|
2593
|
+
this.#controlAttachRetryScheduled = true;
|
|
2594
|
+
afterNextRender(() => {
|
|
2595
|
+
this.#controlAttachTick.update((v) => v + 1);
|
|
2596
|
+
}, { injector: this.#injector });
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2074
2599
|
// Listen to control changes
|
|
2075
2600
|
const sub = control.control?.statusChanges?.subscribe(() => {
|
|
2076
2601
|
const { status, valid, invalid, pending, disabled, pristine, errors, touched, } = control;
|
|
@@ -2168,6 +2693,23 @@ class FormControlStateDirective {
|
|
|
2168
2693
|
? true
|
|
2169
2694
|
: state.hasBeenValidated,
|
|
2170
2695
|
}));
|
|
2696
|
+
// Keep the derived control-state signal in sync even when blur/dirty
|
|
2697
|
+
// changes do not produce a statusChanges emission.
|
|
2698
|
+
//
|
|
2699
|
+
// This happens when a control is already INVALID due to dependent-field
|
|
2700
|
+
// validation and the user then blurs it. Error display modes that depend
|
|
2701
|
+
// on `isTouched()` must still update immediately in that case.
|
|
2702
|
+
this.#controlStateSignal.set({
|
|
2703
|
+
status: control.status,
|
|
2704
|
+
isValid: control.valid ?? false,
|
|
2705
|
+
isInvalid: control.invalid ?? false,
|
|
2706
|
+
isPending: control.pending ?? false,
|
|
2707
|
+
isDisabled: control.disabled ?? false,
|
|
2708
|
+
isTouched: newTouched,
|
|
2709
|
+
isDirty: newDirty,
|
|
2710
|
+
isPristine: control.pristine ?? true,
|
|
2711
|
+
errors: control.errors,
|
|
2712
|
+
});
|
|
2171
2713
|
}
|
|
2172
2714
|
// Sync pending state only when it transitions from true to false
|
|
2173
2715
|
// This fixes "Validating..." being stuck when statusChanges misses the transition
|
|
@@ -2218,10 +2760,10 @@ class FormControlStateDirective {
|
|
|
2218
2760
|
}
|
|
2219
2761
|
return result;
|
|
2220
2762
|
}
|
|
2221
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2222
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.
|
|
2763
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2764
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.2.11", type: FormControlStateDirective, isStandalone: true, selector: "[formControlState], [ngxControlState]", queries: [{ propertyName: "contentNgModel", first: true, predicate: NgModel, descendants: true, isSignal: true }, { propertyName: "contentNgModelGroup", first: true, predicate: NgModelGroup, descendants: true, isSignal: true }], exportAs: ["formControlState", "ngxControlState"], ngImport: i0 }); }
|
|
2223
2765
|
}
|
|
2224
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
2766
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormControlStateDirective, decorators: [{
|
|
2225
2767
|
type: Directive,
|
|
2226
2768
|
args: [{
|
|
2227
2769
|
selector: '[formControlState], [ngxControlState]',
|
|
@@ -2278,13 +2820,13 @@ class FormErrorDisplayDirective {
|
|
|
2278
2820
|
*/
|
|
2279
2821
|
this.errorDisplayMode = input(inject(NGX_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
2280
2822
|
inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
2281
|
-
SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
|
|
2823
|
+
SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : /* istanbul ignore next */ []));
|
|
2282
2824
|
/**
|
|
2283
2825
|
* Input signal for warning display mode.
|
|
2284
2826
|
* Controls whether warnings are shown only after touch or also after validation.
|
|
2285
2827
|
*/
|
|
2286
2828
|
this.warningDisplayMode = input(inject(NGX_WARNING_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
2287
|
-
SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : []));
|
|
2829
|
+
SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : /* istanbul ignore next */ []));
|
|
2288
2830
|
// Expose state signals from FormControlStateDirective
|
|
2289
2831
|
this.controlState = this.#formControlState.controlState;
|
|
2290
2832
|
this.errorMessages = this.#formControlState.errorMessages;
|
|
@@ -2309,20 +2851,25 @@ class FormErrorDisplayDirective {
|
|
|
2309
2851
|
* This keeps programmatic `NgForm.onSubmit()` reactive in zoneless mode and
|
|
2310
2852
|
* avoids depending on `NgForm.submitted`, whose getter intentionally reads an
|
|
2311
2853
|
* internal signal with `untracked()`.
|
|
2854
|
+
*
|
|
2855
|
+
* Note: when this directive is used outside an `NgForm` (no parent form), no
|
|
2856
|
+
* subscription is wired up and this signal stays `false` for the lifetime of
|
|
2857
|
+
* the directive. Consumers relying on submitted state must host the field
|
|
2858
|
+
* inside an `NgForm` (or `ngxVestForm`).
|
|
2312
2859
|
*/
|
|
2313
2860
|
this.formSubmitted = this.#formSubmittedState;
|
|
2314
2861
|
/**
|
|
2315
2862
|
* Determines if errors should be shown based on the specified display mode
|
|
2316
|
-
* and the control's state (touched/submitted/
|
|
2863
|
+
* and the control's state (touched/submitted/dirty).
|
|
2317
2864
|
*
|
|
2318
2865
|
* Note: We check both hasErrors (extracted error messages) AND isInvalid (Angular's validation state)
|
|
2319
2866
|
* because in some cases (like conditional validations via validationConfig), the control is marked
|
|
2320
2867
|
* as invalid by Angular before error messages are extracted from Vest. This ensures aria-invalid
|
|
2321
2868
|
* is set correctly even during the validation propagation delay.
|
|
2322
2869
|
*
|
|
2323
|
-
* For validationConfig-triggered validations
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2870
|
+
* For validationConfig-triggered validations, a field may become invalid before it has been
|
|
2871
|
+
* touched. Error visibility still respects the field's own `errorDisplayMode`, so untouched
|
|
2872
|
+
* dependent fields can remain visually quiet until blur or submit.
|
|
2326
2873
|
*/
|
|
2327
2874
|
this.shouldShowErrors = computed(() => {
|
|
2328
2875
|
const mode = this.errorDisplayMode();
|
|
@@ -2358,7 +2905,7 @@ class FormErrorDisplayDirective {
|
|
|
2358
2905
|
// Show after blur (touch) OR submit (default behavior)
|
|
2359
2906
|
return !!((isTouched || formSubmitted) && hasErrorState);
|
|
2360
2907
|
}
|
|
2361
|
-
}, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
|
|
2908
|
+
}, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : /* istanbul ignore next */ []));
|
|
2362
2909
|
/**
|
|
2363
2910
|
* Errors to display (filtered for pending state)
|
|
2364
2911
|
*/
|
|
@@ -2366,7 +2913,7 @@ class FormErrorDisplayDirective {
|
|
|
2366
2913
|
if (this.hasPendingValidation())
|
|
2367
2914
|
return [];
|
|
2368
2915
|
return this.errorMessages();
|
|
2369
|
-
}, ...(ngDevMode ? [{ debugName: "errors" }] : []));
|
|
2916
|
+
}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
|
|
2370
2917
|
/**
|
|
2371
2918
|
* Warnings to display (filtered for pending state)
|
|
2372
2919
|
*/
|
|
@@ -2374,7 +2921,7 @@ class FormErrorDisplayDirective {
|
|
|
2374
2921
|
if (this.hasPendingValidation())
|
|
2375
2922
|
return [];
|
|
2376
2923
|
return this.warningMessages();
|
|
2377
|
-
}, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
|
|
2924
|
+
}, ...(ngDevMode ? [{ debugName: "warnings" }] : /* istanbul ignore next */ []));
|
|
2378
2925
|
/**
|
|
2379
2926
|
* Whether the control is currently being validated (pending)
|
|
2380
2927
|
* Excludes pristine+untouched controls to prevent "Validating..." on initial load
|
|
@@ -2387,7 +2934,7 @@ class FormErrorDisplayDirective {
|
|
|
2387
2934
|
return false;
|
|
2388
2935
|
}
|
|
2389
2936
|
return this.hasPendingValidation();
|
|
2390
|
-
}, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
2937
|
+
}, ...(ngDevMode ? [{ debugName: "isPending" }] : /* istanbul ignore next */ []));
|
|
2391
2938
|
/**
|
|
2392
2939
|
* Determines if warnings should be shown based on the specified display mode
|
|
2393
2940
|
* and the control's state (touched/validated/dirty).
|
|
@@ -2428,7 +2975,7 @@ class FormErrorDisplayDirective {
|
|
|
2428
2975
|
// Show after validation runs or after touch/submit (default behavior)
|
|
2429
2976
|
return hasBeenValidated || isTouched || formSubmitted;
|
|
2430
2977
|
}
|
|
2431
|
-
}, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : []));
|
|
2978
|
+
}, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : /* istanbul ignore next */ []));
|
|
2432
2979
|
const ngForm = this.#ngForm;
|
|
2433
2980
|
if (ngForm) {
|
|
2434
2981
|
ngForm.form.events
|
|
@@ -2448,10 +2995,10 @@ class FormErrorDisplayDirective {
|
|
|
2448
2995
|
}
|
|
2449
2996
|
});
|
|
2450
2997
|
}
|
|
2451
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2452
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.
|
|
2998
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2999
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: FormErrorDisplayDirective, isStandalone: true, selector: "[formErrorDisplay], [ngxErrorDisplay]", inputs: { errorDisplayMode: { classPropertyName: "errorDisplayMode", publicName: "errorDisplayMode", isSignal: true, isRequired: false, transformFunction: null }, warningDisplayMode: { classPropertyName: "warningDisplayMode", publicName: "warningDisplayMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorDisplay", "ngxErrorDisplay"], hostDirectives: [{ directive: FormControlStateDirective }], ngImport: i0 }); }
|
|
2453
3000
|
}
|
|
2454
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3001
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
|
|
2455
3002
|
type: Directive,
|
|
2456
3003
|
args: [{
|
|
2457
3004
|
selector: '[formErrorDisplay], [ngxErrorDisplay]',
|
|
@@ -2536,9 +3083,17 @@ function resolveAssociationTargets(controls, mode) {
|
|
|
2536
3083
|
* @returns Object containing the debounced showPendingMessage signal and cleanup function
|
|
2537
3084
|
*/
|
|
2538
3085
|
function createDebouncedPendingState(isPending, options = {}) {
|
|
2539
|
-
|
|
3086
|
+
// Reading the options inside the effect makes the timings reactive: when
|
|
3087
|
+
// a consumer passes an `input()` accessor (a Signal), changes propagate at
|
|
3088
|
+
// runtime rather than getting captured once at construction.
|
|
3089
|
+
const optionsSignal = isSignal(options)
|
|
3090
|
+
? options
|
|
3091
|
+
: null;
|
|
3092
|
+
const staticOptions = optionsSignal
|
|
3093
|
+
? {}
|
|
3094
|
+
: options;
|
|
2540
3095
|
// Create writable signal for debounced state
|
|
2541
|
-
const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : []));
|
|
3096
|
+
const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : /* istanbul ignore next */ []));
|
|
2542
3097
|
// Track timeouts
|
|
2543
3098
|
let pendingTimeout = null;
|
|
2544
3099
|
let minimumDisplayTimeout = null;
|
|
@@ -2556,6 +3111,9 @@ function createDebouncedPendingState(isPending, options = {}) {
|
|
|
2556
3111
|
// Effect to manage debounced pending message display
|
|
2557
3112
|
effect((onCleanup) => {
|
|
2558
3113
|
const pending = isPending();
|
|
3114
|
+
const current = optionsSignal ? optionsSignal() : staticOptions;
|
|
3115
|
+
const showAfter = current.showAfter ?? 200;
|
|
3116
|
+
const minimumDisplay = current.minimumDisplay ?? 500;
|
|
2559
3117
|
if (pending) {
|
|
2560
3118
|
// Clear any existing minimum display timeout
|
|
2561
3119
|
if (minimumDisplayTimeout) {
|
|
@@ -2740,16 +3298,16 @@ class ControlWrapperComponent {
|
|
|
2740
3298
|
* across multiple child controls.
|
|
2741
3299
|
* - This does not affect whether messages render; it only affects ARIA wiring.
|
|
2742
3300
|
*/
|
|
2743
|
-
this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
|
|
3301
|
+
this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : /* istanbul ignore next */ []));
|
|
2744
3302
|
// Generate unique IDs for ARIA associations
|
|
2745
3303
|
this.uniqueId = `ngx-control-wrapper-${nextUniqueId$2++}`;
|
|
2746
3304
|
this.errorId = `${this.uniqueId}-error`;
|
|
2747
3305
|
this.warningId = `${this.uniqueId}-warning`;
|
|
2748
3306
|
this.pendingId = `${this.uniqueId}-pending`;
|
|
2749
3307
|
// Track form controls found in the wrapper
|
|
2750
|
-
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
|
|
3308
|
+
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : /* istanbul ignore next */ []));
|
|
2751
3309
|
// Signals when content is initialized so effects can safely touch the DOM.
|
|
2752
|
-
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
|
|
3310
|
+
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : /* istanbul ignore next */ []));
|
|
2753
3311
|
// MutationObserver to detect dynamically added/removed controls
|
|
2754
3312
|
this.mutationObserver = null;
|
|
2755
3313
|
/**
|
|
@@ -2781,7 +3339,7 @@ class ControlWrapperComponent {
|
|
|
2781
3339
|
ids.push(this.pendingId);
|
|
2782
3340
|
}
|
|
2783
3341
|
return ids.length > 0 ? ids.join(' ') : null;
|
|
2784
|
-
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
|
|
3342
|
+
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : /* istanbul ignore next */ []));
|
|
2785
3343
|
/**
|
|
2786
3344
|
* IDs managed by this wrapper when composing aria-describedby.
|
|
2787
3345
|
*
|
|
@@ -2874,10 +3432,10 @@ class ControlWrapperComponent {
|
|
|
2874
3432
|
const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
|
|
2875
3433
|
this.formControls.set(Array.from(controls));
|
|
2876
3434
|
}
|
|
2877
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2878
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.
|
|
3435
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3436
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.11", type: ControlWrapperComponent, isStandalone: true, selector: "ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-control-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-control-wrapper sc-control-wrapper" }, hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode", "warningDisplayMode", "warningDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n WCAG 2.2 AA Compliance for Inline Field Validation:\n\n IMPORTANT DISTINCTION - Field-level vs Form-level errors:\n - FIELD-LEVEL errors (this component): Use role=\"status\" with aria-live=\"polite\"\n These are inline validation messages that appear as users interact with fields.\n Using \"assertive\" would be too disruptive when users are filling out multiple fields.\n - FORM-LEVEL blocking errors (e.g., submission failed): Should use role=\"alert\" with\n aria-live=\"assertive\" in a separate form-level error summary component.\n\n This component handles FIELD-LEVEL messages:\n - Errors: role=\"status\" with aria-live=\"polite\" (non-disruptive, field is already\n marked with aria-invalid=\"true\" and aria-describedby points here)\n - Warnings: role=\"status\" with aria-live=\"polite\" (informational, doesn't block)\n - Pending: role=\"status\" with aria-live=\"polite\" (status update)\n\n The aria-invalid=\"true\" + aria-describedby association on the input provides\n the primary accessibility pathway; the live region is supplementary.\n\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA21 - Using aria-invalid\n @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - Using role=status\n-->\n<div\n [id]=\"errorId\"\n class=\"text-sm text-red-600\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (errorDisplay.shouldShowErrors()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<!--\n Warnings: Non-blocking validation messages (Vest warn())\n - Use role=\"status\" with aria-live=\"polite\" per WCAG ARIA22\n - Default: show after validation runs or touch; configurable to require touch only\n - Warnings do NOT affect field validity - they're informational only\n-->\n<div\n [id]=\"warningId\"\n class=\"text-sm text-yellow-700\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (shouldShowWarnings()) {\n <ul class=\"m-0 mt-1 list-none space-y-1 p-0\">\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<!-- Pending state is also stable; content appears only after the debounce delay -->\n<div\n [id]=\"pendingId\"\n class=\"absolute top-0 right-0 flex items-center gap-1 text-xs text-gray-500\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n>\n @if (showPendingMessage()) {\n <span\n class=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent\"\n aria-hidden=\"true\"\n ></span>\n Validating\u2026\n }\n</div>\n", styles: [":host{display:block;position:relative}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2879
3437
|
}
|
|
2880
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3438
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ControlWrapperComponent, decorators: [{
|
|
2881
3439
|
type: Component,
|
|
2882
3440
|
args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
2883
3441
|
class: 'ngx-control-wrapper sc-control-wrapper',
|
|
@@ -2913,12 +3471,12 @@ class FormGroupWrapperComponent {
|
|
|
2913
3471
|
this.pendingDebounce = input({
|
|
2914
3472
|
showAfter: 500,
|
|
2915
3473
|
minimumDisplay: 500,
|
|
2916
|
-
}, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : []));
|
|
3474
|
+
}, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : /* istanbul ignore next */ []));
|
|
2917
3475
|
this.uniqueId = `ngx-form-group-wrapper-${nextUniqueId$1++}`;
|
|
2918
3476
|
this.errorId = `${this.uniqueId}-error`;
|
|
2919
3477
|
this.warningId = `${this.uniqueId}-warning`;
|
|
2920
3478
|
this.pendingId = `${this.uniqueId}-pending`;
|
|
2921
|
-
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce
|
|
3479
|
+
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce);
|
|
2922
3480
|
this.showPendingMessage = this.pendingState.showPendingMessage;
|
|
2923
3481
|
/**
|
|
2924
3482
|
* Helpful if consumers want to wire aria-describedby manually (e.g. fieldset/legend pattern).
|
|
@@ -2935,12 +3493,12 @@ class FormGroupWrapperComponent {
|
|
|
2935
3493
|
ids.push(this.pendingId);
|
|
2936
3494
|
}
|
|
2937
3495
|
return ids.length > 0 ? ids.join(' ') : null;
|
|
2938
|
-
}, ...(ngDevMode ? [{ debugName: "describedByIds" }] : []));
|
|
3496
|
+
}, ...(ngDevMode ? [{ debugName: "describedByIds" }] : /* istanbul ignore next */ []));
|
|
2939
3497
|
}
|
|
2940
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
2941
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.
|
|
3498
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3499
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.11", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper]", inputs: { pendingDebounce: { classPropertyName: "pendingDebounce", publicName: "pendingDebounce", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.ngx-form-group-wrapper--invalid": "errorDisplay.shouldShowErrors()", "attr.aria-busy": "errorDisplay.isPending() ? 'true' : null" }, classAttribute: "ngx-form-group-wrapper sc-form-group-wrapper" }, exportAs: ["ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode", "warningDisplayMode", "warningDisplayMode"] }], ngImport: i0, template: "<div class=\"ngx-form-group-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Keep regions stable in the DOM so IDs are always valid targets.\n This wrapper does NOT modify descendant controls.\n-->\n<div [id]=\"errorId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowErrors() && errorDisplay.errors().length > 0) {\n <ul>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"warningId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (errorDisplay.shouldShowWarnings()) {\n <ul>\n @for (warn of errorDisplay.warnings(); track warn) {\n <li>{{ warn }}</li>\n }\n </ul>\n }\n</div>\n\n<div [id]=\"pendingId\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n @if (showPendingMessage()) {\n <span aria-hidden=\"true\">Validating\u2026</span>\n }\n</div>\n", styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2942
3500
|
}
|
|
2943
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3501
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
|
|
2944
3502
|
type: Component,
|
|
2945
3503
|
args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper]', exportAs: 'ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
2946
3504
|
class: 'ngx-form-group-wrapper sc-form-group-wrapper',
|
|
@@ -2977,7 +3535,7 @@ class FormErrorControlDirective {
|
|
|
2977
3535
|
* - `single-control`: apply ARIA attributes only when exactly one control is found.
|
|
2978
3536
|
* - `none`: do not mutate descendant controls.
|
|
2979
3537
|
*/
|
|
2980
|
-
this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
|
|
3538
|
+
this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : /* istanbul ignore next */ []));
|
|
2981
3539
|
/**
|
|
2982
3540
|
* Unique ID prefix for this instance.
|
|
2983
3541
|
* Use these IDs to render message regions and to support aria-describedby.
|
|
@@ -2986,8 +3544,8 @@ class FormErrorControlDirective {
|
|
|
2986
3544
|
this.errorId = `${this.uniqueId}-error`;
|
|
2987
3545
|
this.warningId = `${this.uniqueId}-warning`;
|
|
2988
3546
|
this.pendingId = `${this.uniqueId}-pending`;
|
|
2989
|
-
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
|
|
2990
|
-
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
|
|
3547
|
+
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : /* istanbul ignore next */ []));
|
|
3548
|
+
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : /* istanbul ignore next */ []));
|
|
2991
3549
|
this.mutationObserver = null;
|
|
2992
3550
|
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
|
|
2993
3551
|
this.showPendingMessage = this.pendingState.showPendingMessage;
|
|
@@ -3006,7 +3564,7 @@ class FormErrorControlDirective {
|
|
|
3006
3564
|
ids.push(this.pendingId);
|
|
3007
3565
|
}
|
|
3008
3566
|
return ids.length > 0 ? ids.join(' ') : null;
|
|
3009
|
-
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
|
|
3567
|
+
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : /* istanbul ignore next */ []));
|
|
3010
3568
|
this.ownedDescribedByIds = [
|
|
3011
3569
|
this.errorId,
|
|
3012
3570
|
this.warningId,
|
|
@@ -3080,10 +3638,10 @@ class FormErrorControlDirective {
|
|
|
3080
3638
|
const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
|
|
3081
3639
|
this.formControls.set(Array.from(controls));
|
|
3082
3640
|
}
|
|
3083
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
3084
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.
|
|
3641
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
3642
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: FormErrorControlDirective, isStandalone: true, selector: "[formErrorControl], [ngxErrorControl]", inputs: { ariaAssociationMode: { classPropertyName: "ariaAssociationMode", publicName: "ariaAssociationMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorControl", "ngxErrorControl"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode", "warningDisplayMode", "warningDisplayMode"] }], ngImport: i0 }); }
|
|
3085
3643
|
}
|
|
3086
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3644
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormErrorControlDirective, decorators: [{
|
|
3087
3645
|
type: Directive,
|
|
3088
3646
|
args: [{
|
|
3089
3647
|
selector: '[formErrorControl], [ngxErrorControl]',
|
|
@@ -3147,7 +3705,7 @@ class FormModelGroupDirective {
|
|
|
3147
3705
|
*
|
|
3148
3706
|
* Defaults to no debounce (`{ debounceTime: 0 }`).
|
|
3149
3707
|
*/
|
|
3150
|
-
this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
|
|
3708
|
+
this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
|
|
3151
3709
|
this.formDirective = inject(FormDirective, { optional: true });
|
|
3152
3710
|
}
|
|
3153
3711
|
/**
|
|
@@ -3163,8 +3721,8 @@ class FormModelGroupDirective {
|
|
|
3163
3721
|
return getFormGroupField(context.ngForm.control, currentControl);
|
|
3164
3722
|
}, this.validationOptions(), 'FormModelGroupDirective');
|
|
3165
3723
|
}
|
|
3166
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
3167
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.
|
|
3724
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
3725
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup],[ngxModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
3168
3726
|
{
|
|
3169
3727
|
provide: NG_ASYNC_VALIDATORS,
|
|
3170
3728
|
useExisting: FormModelGroupDirective,
|
|
@@ -3172,7 +3730,7 @@ class FormModelGroupDirective {
|
|
|
3172
3730
|
},
|
|
3173
3731
|
], ngImport: i0 }); }
|
|
3174
3732
|
}
|
|
3175
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3733
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelGroupDirective, decorators: [{
|
|
3176
3734
|
type: Directive,
|
|
3177
3735
|
args: [{
|
|
3178
3736
|
selector: '[ngModelGroup],[ngxModelGroup]',
|
|
@@ -3197,7 +3755,7 @@ class FormModelDirective {
|
|
|
3197
3755
|
*
|
|
3198
3756
|
* Defaults to no debounce (`{ debounceTime: 0 }`).
|
|
3199
3757
|
*/
|
|
3200
|
-
this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
|
|
3758
|
+
this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
|
|
3201
3759
|
/**
|
|
3202
3760
|
* Reference to the form that needs to be validated
|
|
3203
3761
|
* Injected optionally so that using ngModel outside of an ngxVestForm
|
|
@@ -3220,8 +3778,8 @@ class FormModelDirective {
|
|
|
3220
3778
|
return getFormControlField(context.ngForm.control, currentControl);
|
|
3221
3779
|
}, this.validationOptions(), 'FormModelDirective');
|
|
3222
3780
|
}
|
|
3223
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
3224
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.
|
|
3781
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
3782
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: FormModelDirective, isStandalone: true, selector: "[ngModel],[ngxModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
3225
3783
|
{
|
|
3226
3784
|
provide: NG_ASYNC_VALIDATORS,
|
|
3227
3785
|
useExisting: FormModelDirective,
|
|
@@ -3229,7 +3787,7 @@ class FormModelDirective {
|
|
|
3229
3787
|
},
|
|
3230
3788
|
], ngImport: i0 }); }
|
|
3231
3789
|
}
|
|
3232
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
3790
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: FormModelDirective, decorators: [{
|
|
3233
3791
|
type: Directive,
|
|
3234
3792
|
args: [{
|
|
3235
3793
|
selector: '[ngModel],[ngxModel]',
|
|
@@ -3318,26 +3876,30 @@ class ValidateRootFormDirective {
|
|
|
3318
3876
|
constructor() {
|
|
3319
3877
|
this.injector = inject(Injector);
|
|
3320
3878
|
this.destroyRef = inject(DestroyRef);
|
|
3321
|
-
this.lastControl = signal(null, ...(ngDevMode ? [{ debugName: "lastControl" }] : []));
|
|
3322
|
-
this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : []));
|
|
3323
|
-
this.hasSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "hasSubmitted" }] : []));
|
|
3324
|
-
this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
|
|
3325
|
-
this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
|
|
3879
|
+
this.lastControl = signal(null, ...(ngDevMode ? [{ debugName: "lastControl" }] : /* istanbul ignore next */ []));
|
|
3880
|
+
this.validationOptions = input({ debounceTime: 0 }, ...(ngDevMode ? [{ debugName: "validationOptions" }] : /* istanbul ignore next */ []));
|
|
3881
|
+
this.hasSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "hasSubmitted" }] : /* istanbul ignore next */ []));
|
|
3882
|
+
this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : /* istanbul ignore next */ []));
|
|
3883
|
+
this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : /* istanbul ignore next */ []));
|
|
3326
3884
|
/**
|
|
3327
3885
|
* Whether the root form should be validated or not
|
|
3328
3886
|
* This will use the field rootForm
|
|
3329
3887
|
* Accepts both validateRootForm and ngxValidateRootForm
|
|
3330
3888
|
*/
|
|
3331
|
-
this.validateRootForm = input(false, { ...(ngDevMode ? { debugName: "validateRootForm" } : {}), transform: booleanAttribute });
|
|
3332
|
-
this.ngxValidateRootForm = input(false, { ...(ngDevMode ? { debugName: "ngxValidateRootForm" } : {}), transform: booleanAttribute });
|
|
3889
|
+
this.validateRootForm = input(false, { ...(ngDevMode ? { debugName: "validateRootForm" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
3890
|
+
this.ngxValidateRootForm = input(false, { ...(ngDevMode ? { debugName: "ngxValidateRootForm" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
3333
3891
|
/**
|
|
3334
3892
|
* Validation mode:
|
|
3335
|
-
* - 'submit' (default): Only validates after form submission
|
|
3336
|
-
* - 'live'
|
|
3337
|
-
*
|
|
3893
|
+
* - `'submit'` (effective default): Only validates after form submission.
|
|
3894
|
+
* - `'live'`: Validates on every value change.
|
|
3895
|
+
*
|
|
3896
|
+
* Both inputs default to `undefined` so we can detect whether the consumer
|
|
3897
|
+
* set them explicitly. Precedence is `ngx ?? legacy ?? 'submit'`, which
|
|
3898
|
+
* matches the documented behavior — observable only when both attributes
|
|
3899
|
+
* are set explicitly on the same form.
|
|
3338
3900
|
*/
|
|
3339
|
-
this.validateRootFormMode = input(
|
|
3340
|
-
this.ngxValidateRootFormMode = input(
|
|
3901
|
+
this.validateRootFormMode = input(undefined, ...(ngDevMode ? [{ debugName: "validateRootFormMode" }] : /* istanbul ignore next */ []));
|
|
3902
|
+
this.ngxValidateRootFormMode = input(undefined, ...(ngDevMode ? [{ debugName: "ngxValidateRootFormMode" }] : /* istanbul ignore next */ []));
|
|
3341
3903
|
// Convert signals to Observables in injection context
|
|
3342
3904
|
this.hasSubmitted$ = toObservable(this.hasSubmitted);
|
|
3343
3905
|
this.formValue$ = toObservable(this.formValue);
|
|
@@ -3362,7 +3924,9 @@ class ValidateRootFormDirective {
|
|
|
3362
3924
|
if (ngForm?.control) {
|
|
3363
3925
|
// Defer to the next microtask so Angular has a chance to finish
|
|
3364
3926
|
// wiring up controls/groups (ngModel/ngModelGroup) on initial render.
|
|
3365
|
-
|
|
3927
|
+
// The scheduleMicrotask primitive auto-cancels if the directive is
|
|
3928
|
+
// destroyed before the microtask fires.
|
|
3929
|
+
scheduleMicrotask(() => ngForm.control.updateValueAndValidity(), this.destroyRef);
|
|
3366
3930
|
}
|
|
3367
3931
|
});
|
|
3368
3932
|
}
|
|
@@ -3384,7 +3948,7 @@ class ValidateRootFormDirective {
|
|
|
3384
3948
|
// Ensure we run at least one validation pass after the form is ready.
|
|
3385
3949
|
// This matters for 'live' mode root-form errors that should appear
|
|
3386
3950
|
// without requiring a user interaction.
|
|
3387
|
-
|
|
3951
|
+
scheduleMicrotask(() => ngForm.control.updateValueAndValidity(), this.destroyRef);
|
|
3388
3952
|
// Subscribe to form submission to set hasSubmitted flag
|
|
3389
3953
|
ngForm.ngSubmit
|
|
3390
3954
|
.pipe(tap(() => {
|
|
@@ -3402,10 +3966,12 @@ class ValidateRootFormDirective {
|
|
|
3402
3966
|
if (!isEnabled) {
|
|
3403
3967
|
return of(null);
|
|
3404
3968
|
}
|
|
3405
|
-
//
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3969
|
+
// Mode precedence: ngx-prefixed input wins over legacy input; both default
|
|
3970
|
+
// to `undefined` so the precedence rule is implementable without losing
|
|
3971
|
+
// the legacy attribute when the new one is not set.
|
|
3972
|
+
const mode = this.ngxValidateRootFormMode() ??
|
|
3973
|
+
this.validateRootFormMode() ??
|
|
3974
|
+
'submit';
|
|
3409
3975
|
// In 'submit' mode, skip validation until form is submitted
|
|
3410
3976
|
if (mode === 'submit' && !this.hasSubmitted()) {
|
|
3411
3977
|
return of(null);
|
|
@@ -3466,8 +4032,8 @@ class ValidateRootFormDirective {
|
|
|
3466
4032
|
}), take(1), takeUntilDestroyed(this.destroyRef));
|
|
3467
4033
|
};
|
|
3468
4034
|
}
|
|
3469
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.
|
|
3470
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.
|
|
4035
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
4036
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.11", type: ValidateRootFormDirective, isStandalone: true, selector: "form[validateRootForm], form[ngxValidateRootForm]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null }, formValue: { classPropertyName: "formValue", publicName: "formValue", isSignal: true, isRequired: false, transformFunction: null }, suite: { classPropertyName: "suite", publicName: "suite", isSignal: true, isRequired: false, transformFunction: null }, validateRootForm: { classPropertyName: "validateRootForm", publicName: "validateRootForm", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootForm: { classPropertyName: "ngxValidateRootForm", publicName: "ngxValidateRootForm", isSignal: true, isRequired: false, transformFunction: null }, validateRootFormMode: { classPropertyName: "validateRootFormMode", publicName: "validateRootFormMode", isSignal: true, isRequired: false, transformFunction: null }, ngxValidateRootFormMode: { classPropertyName: "ngxValidateRootFormMode", publicName: "ngxValidateRootFormMode", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
3471
4037
|
{
|
|
3472
4038
|
provide: NG_ASYNC_VALIDATORS,
|
|
3473
4039
|
useExisting: ValidateRootFormDirective,
|
|
@@ -3475,7 +4041,7 @@ class ValidateRootFormDirective {
|
|
|
3475
4041
|
},
|
|
3476
4042
|
], ngImport: i0 }); }
|
|
3477
4043
|
}
|
|
3478
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.
|
|
4044
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
|
|
3479
4045
|
type: Directive,
|
|
3480
4046
|
args: [{
|
|
3481
4047
|
selector: 'form[validateRootForm], form[ngxValidateRootForm]',
|
|
@@ -3702,7 +4268,7 @@ class ValidationConfigBuilder {
|
|
|
3702
4268
|
*/
|
|
3703
4269
|
whenChanged(trigger, revalidate) {
|
|
3704
4270
|
const deps = Array.isArray(revalidate) ? revalidate : [revalidate];
|
|
3705
|
-
const existing = this.config[trigger] || [];
|
|
4271
|
+
const existing = (this.config[trigger] || []);
|
|
3706
4272
|
// Development mode warning for duplicate dependents
|
|
3707
4273
|
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
|
|
3708
4274
|
const duplicates = deps.filter((d) => existing.includes(d));
|
|
@@ -4324,7 +4890,7 @@ function keepFieldsWhen(currentState, conditions) {
|
|
|
4324
4890
|
const result = {};
|
|
4325
4891
|
for (const fieldName of Object.keys(conditions)) {
|
|
4326
4892
|
const shouldKeep = conditions[fieldName];
|
|
4327
|
-
if (shouldKeep && fieldName
|
|
4893
|
+
if (shouldKeep && Object.hasOwn(currentState, fieldName)) {
|
|
4328
4894
|
result[fieldName] = currentState[fieldName];
|
|
4329
4895
|
}
|
|
4330
4896
|
}
|
|
@@ -4339,5 +4905,5 @@ function keepFieldsWhen(currentState, conditions) {
|
|
|
4339
4905
|
* Generated bundle index. Do not edit.
|
|
4340
4906
|
*/
|
|
4341
4907
|
|
|
4342
|
-
export { ControlWrapperComponent, DEFAULT_FOCUS_SELECTOR, DEFAULT_INVALID_SELECTOR, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NGX_WARNING_DISPLAY_MODE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeAriaDescribedBy, mergeValuesAndRawValues, objectToArray, parseAriaIdTokens, parseFieldPath, resolveAssociationTargets, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
|
|
4908
|
+
export { ControlWrapperComponent, DEFAULT_FOCUS_SELECTOR, DEFAULT_INVALID_SELECTOR, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_EQUALITY_FN, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NGX_WARNING_DISPLAY_MODE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeAriaDescribedBy, mergeValuesAndRawValues, objectToArray, parseAriaIdTokens, parseFieldPath, resolveAssociationTargets, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
|
|
4343
4909
|
//# sourceMappingURL=ngx-vest-forms.mjs.map
|