ngx-vest-forms 2.1.0 → 2.2.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 +10 -0
- package/fesm2022/ngx-vest-forms.mjs +2167 -1963
- package/fesm2022/ngx-vest-forms.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-vest-forms.d.ts +129 -61
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken,
|
|
3
|
-
import {
|
|
2
|
+
import { InjectionToken, isDevMode, inject, DestroyRef, ChangeDetectorRef, signal, linkedSignal, computed, input, effect, untracked, Directive, contentChild, Injector, afterEveryRender, ElementRef, ChangeDetectionStrategy, Component, booleanAttribute, Optional } from '@angular/core';
|
|
3
|
+
import { FormGroup, FormArray, NgForm, StatusChangeEvent, ValueChangeEvent, PristineChangeEvent, NgModel, NgModelGroup, NG_ASYNC_VALIDATORS, ControlContainer, FormsModule } from '@angular/forms';
|
|
4
4
|
import { toSignal, outputFromObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
5
|
-
import {
|
|
5
|
+
import { startWith, filter, map, distinctUntilChanged, merge, switchMap, take, of, timer, Observable, catchError, EMPTY, debounceTime, tap, race, from } from 'rxjs';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @deprecated Use NGX_ERROR_DISPLAY_MODE_TOKEN instead
|
|
@@ -19,1010 +19,303 @@ const NGX_ERROR_DISPLAY_MODE_TOKEN = new InjectionToken('NGX_ERROR_DISPLAY_MODE_
|
|
|
19
19
|
providedIn: 'root',
|
|
20
20
|
factory: () => 'on-blur-or-submit',
|
|
21
21
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
#interactionState;
|
|
48
|
-
/**
|
|
49
|
-
* Track whether this control has been validated at least once.
|
|
50
|
-
* This is separate from touched - a field can be validated via validationConfig
|
|
51
|
-
* without being touched by the user. This flag enables showing errors for
|
|
52
|
-
* validationConfig-triggered validations even before user interaction.
|
|
53
|
-
*/
|
|
54
|
-
#hasBeenValidated;
|
|
55
|
-
/**
|
|
56
|
-
* Track the previous status to detect actual status changes (not just status emissions).
|
|
57
|
-
* This helps distinguish between initial control creation and actual re-validation.
|
|
58
|
-
*/
|
|
59
|
-
#previousStatus;
|
|
60
|
-
/**
|
|
61
|
-
* Internal signal for control state (updated reactively)
|
|
62
|
-
*/
|
|
63
|
-
#controlStateSignal;
|
|
64
|
-
constructor() {
|
|
65
|
-
this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
|
|
66
|
-
this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : []));
|
|
67
|
-
this.#hostNgModel = inject(NgModel, {
|
|
68
|
-
self: true,
|
|
69
|
-
optional: true,
|
|
70
|
-
});
|
|
71
|
-
this.#hostNgModelGroup = inject(NgModelGroup, {
|
|
72
|
-
self: true,
|
|
73
|
-
optional: true,
|
|
74
|
-
});
|
|
75
|
-
this.#injector = inject(Injector);
|
|
76
|
-
/**
|
|
77
|
-
* Computed signal for the active control (NgModel or NgModelGroup)
|
|
78
|
-
*/
|
|
79
|
-
this.#activeControl = computed(() => {
|
|
80
|
-
return (this.#hostNgModel ||
|
|
81
|
-
this.#hostNgModelGroup ||
|
|
82
|
-
this.contentNgModel() ||
|
|
83
|
-
this.contentNgModelGroup() ||
|
|
84
|
-
null);
|
|
85
|
-
}, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
|
|
86
|
-
/**
|
|
87
|
-
* Internal signal for robust touched/dirty tracking (syncs after every render)
|
|
88
|
-
*/
|
|
89
|
-
this.#interactionState = signal({
|
|
90
|
-
isTouched: false,
|
|
91
|
-
isDirty: false,
|
|
92
|
-
}, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
|
|
93
|
-
/**
|
|
94
|
-
* Track whether this control has been validated at least once.
|
|
95
|
-
* This is separate from touched - a field can be validated via validationConfig
|
|
96
|
-
* without being touched by the user. This flag enables showing errors for
|
|
97
|
-
* validationConfig-triggered validations even before user interaction.
|
|
98
|
-
*/
|
|
99
|
-
this.#hasBeenValidated = signal(false, ...(ngDevMode ? [{ debugName: "#hasBeenValidated" }] : []));
|
|
100
|
-
/**
|
|
101
|
-
* Track the previous status to detect actual status changes (not just status emissions).
|
|
102
|
-
* This helps distinguish between initial control creation and actual re-validation.
|
|
103
|
-
*/
|
|
104
|
-
this.#previousStatus = undefined;
|
|
105
|
-
/**
|
|
106
|
-
* Internal signal for control state (updated reactively)
|
|
107
|
-
*/
|
|
108
|
-
this.#controlStateSignal = signal(getInitialFormControlState(), ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : []));
|
|
109
|
-
/**
|
|
110
|
-
* Main control state computed signal (merges robust touched/dirty)
|
|
111
|
-
*/
|
|
112
|
-
this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
|
|
113
|
-
/**
|
|
114
|
-
* Extracts error messages from Angular/Vest errors (recursively flattens)
|
|
115
|
-
*/
|
|
116
|
-
this.errorMessages = computed(() => {
|
|
117
|
-
const state = this.controlState();
|
|
118
|
-
if (!state?.errors)
|
|
119
|
-
return [];
|
|
120
|
-
// Vest errors are stored in the 'errors' property as an array
|
|
121
|
-
const vestErrors = state.errors['errors'];
|
|
122
|
-
if (Array.isArray(vestErrors)) {
|
|
123
|
-
return vestErrors;
|
|
124
|
-
}
|
|
125
|
-
// Fallback to flattened Angular error keys
|
|
126
|
-
return this.#flattenAngularErrors(state.errors);
|
|
127
|
-
}, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
|
|
128
|
-
/**
|
|
129
|
-
* ADVANCED: updateOn strategy (change/blur/submit) if available
|
|
130
|
-
*/
|
|
131
|
-
this.updateOn = computed(() => {
|
|
132
|
-
const ngModel = this.contentNgModel() || this.#hostNgModel;
|
|
133
|
-
// Angular's NgModel.options?.updateOn
|
|
134
|
-
return ngModel?.options?.updateOn ?? 'change';
|
|
135
|
-
}, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
|
|
136
|
-
/**
|
|
137
|
-
* ADVANCED: Composite/derived signals for advanced error display logic
|
|
138
|
-
*/
|
|
139
|
-
this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
|
|
140
|
-
this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
|
|
141
|
-
this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
|
|
142
|
-
/**
|
|
143
|
-
* Extracts warning messages from Vest validation results (robust)
|
|
144
|
-
*/
|
|
145
|
-
this.warningMessages = computed(() => {
|
|
146
|
-
const state = this.controlState();
|
|
147
|
-
if (!state?.errors)
|
|
148
|
-
return [];
|
|
149
|
-
const warnings = state.errors['warnings'];
|
|
150
|
-
if (Array.isArray(warnings)) {
|
|
151
|
-
return warnings;
|
|
152
|
-
}
|
|
153
|
-
// Optionally, flatten nested warnings if needed in future
|
|
154
|
-
return [];
|
|
155
|
-
}, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
|
|
156
|
-
/**
|
|
157
|
-
* Whether async validation is in progress
|
|
158
|
-
*/
|
|
159
|
-
this.hasPendingValidation = computed(() => !!this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
|
|
160
|
-
/**
|
|
161
|
-
* Convenience signals for common state checks
|
|
162
|
-
*/
|
|
163
|
-
this.isValid = computed(() => this.controlState().isValid || false, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
|
|
164
|
-
this.isInvalid = computed(() => this.controlState().isInvalid || false, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
|
|
165
|
-
this.isPending = computed(() => this.controlState().isPending || false, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
166
|
-
this.isTouched = computed(() => this.controlState().isTouched || false, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
|
|
167
|
-
this.isDirty = computed(() => this.controlState().isDirty || false, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
|
|
168
|
-
this.isPristine = computed(() => this.controlState().isPristine || false, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
|
|
169
|
-
this.isDisabled = computed(() => this.controlState().isDisabled || false, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
|
|
170
|
-
this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : []));
|
|
171
|
-
/**
|
|
172
|
-
* Whether this control has been validated at least once.
|
|
173
|
-
* True after the first validation completes, even if the user hasn't touched the field.
|
|
174
|
-
* This enables showing errors for validationConfig-triggered validations.
|
|
175
|
-
*/
|
|
176
|
-
this.hasBeenValidated = computed(() => this.#hasBeenValidated(), ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : []));
|
|
177
|
-
// Update control state reactively
|
|
178
|
-
effect(() => {
|
|
179
|
-
const control = this.#activeControl();
|
|
180
|
-
const interaction = this.#interactionState();
|
|
181
|
-
if (!control) {
|
|
182
|
-
this.#controlStateSignal.set(getInitialFormControlState());
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
// Listen to control changes
|
|
186
|
-
const sub = control.control?.statusChanges?.subscribe(() => {
|
|
187
|
-
const { status, valid, invalid, pending, disabled, pristine, errors, touched, dirty, } = control;
|
|
188
|
-
// Mark as validated when any of the following conditions are met:
|
|
189
|
-
// 1. The control has been touched (user blurred the field).
|
|
190
|
-
// 2. The control's status has actually changed (not the first status emission),
|
|
191
|
-
// AND the new status is not 'PENDING' (validation completed),
|
|
192
|
-
// AND the control has been interacted with (dirty).
|
|
193
|
-
//
|
|
194
|
-
// This ensures hasBeenValidated is true for:
|
|
195
|
-
// - User blur events (touched becomes true)
|
|
196
|
-
// - User-triggered validations (dirty)
|
|
197
|
-
// - ValidationConfig-triggered validations that result in the control becoming touched
|
|
198
|
-
// But NOT for initial page load validations.
|
|
199
|
-
//
|
|
200
|
-
// Accessibility: The logic is structured for clarity and maintainability.
|
|
201
|
-
// IMPORTANT: Read touched/dirty directly from control, not from signal,
|
|
202
|
-
// to avoid race conditions with afterEveryRender sync.
|
|
203
|
-
if (touched || // Control was blurred (most common case)
|
|
204
|
-
(this.#previousStatus !== undefined && // Not the first status emission
|
|
205
|
-
this.#previousStatus !== status && // Status actually changed
|
|
206
|
-
status &&
|
|
207
|
-
status !== 'PENDING' &&
|
|
208
|
-
dirty) // Or control value changed (typed)
|
|
209
|
-
) {
|
|
210
|
-
this.#hasBeenValidated.set(true);
|
|
211
|
-
}
|
|
212
|
-
// Track current status for next iteration
|
|
213
|
-
this.#previousStatus = status;
|
|
214
|
-
this.#controlStateSignal.set({
|
|
215
|
-
status,
|
|
216
|
-
isValid: valid,
|
|
217
|
-
isInvalid: invalid,
|
|
218
|
-
isPending: pending,
|
|
219
|
-
isDisabled: disabled,
|
|
220
|
-
isTouched: interaction.isTouched,
|
|
221
|
-
isDirty: interaction.isDirty,
|
|
222
|
-
isPristine: pristine,
|
|
223
|
-
errors,
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
// Initial update
|
|
227
|
-
const { status, valid, invalid, pending, disabled, pristine, errors } = control;
|
|
228
|
-
// Set initial previous status
|
|
229
|
-
this.#previousStatus = status;
|
|
230
|
-
this.#controlStateSignal.set({
|
|
231
|
-
status,
|
|
232
|
-
isValid: valid,
|
|
233
|
-
isInvalid: invalid,
|
|
234
|
-
isPending: pending,
|
|
235
|
-
isDisabled: disabled,
|
|
236
|
-
isTouched: interaction.isTouched,
|
|
237
|
-
isDirty: interaction.isDirty,
|
|
238
|
-
isPristine: pristine,
|
|
239
|
-
errors,
|
|
240
|
-
});
|
|
241
|
-
return () => sub?.unsubscribe();
|
|
242
|
-
});
|
|
243
|
-
// Robustly sync touched/dirty after every render (Angular 18+ best practice)
|
|
244
|
-
afterEveryRender(() => {
|
|
245
|
-
const control = this.#activeControl();
|
|
246
|
-
if (control) {
|
|
247
|
-
const current = this.#interactionState();
|
|
248
|
-
const newTouched = control.touched ?? false;
|
|
249
|
-
const newDirty = control.dirty ?? false;
|
|
250
|
-
if (newTouched !== current.isTouched ||
|
|
251
|
-
newDirty !== current.isDirty) {
|
|
252
|
-
this.#interactionState.set({
|
|
253
|
-
isTouched: newTouched,
|
|
254
|
-
isDirty: newDirty,
|
|
255
|
-
});
|
|
256
|
-
// Mark as validated when control becomes touched (e.g., user blurred the field)
|
|
257
|
-
// This handles the case where blur doesn't trigger statusChanges (field already invalid)
|
|
258
|
-
if (newTouched && !current.isTouched) {
|
|
259
|
-
this.#hasBeenValidated.set(true);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}, { injector: this.#injector });
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Recursively flattens Angular error objects into an array of error keys.
|
|
267
|
-
*/
|
|
268
|
-
#flattenAngularErrors(errors) {
|
|
269
|
-
const result = [];
|
|
270
|
-
for (const key of Object.keys(errors)) {
|
|
271
|
-
const value = errors[key];
|
|
272
|
-
if (typeof value === 'object' && value !== null) {
|
|
273
|
-
result.push(...this.#flattenAngularErrors(value));
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
result.push(key);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return result;
|
|
280
|
-
}
|
|
281
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
282
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.0", 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 }); }
|
|
283
|
-
}
|
|
284
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormControlStateDirective, decorators: [{
|
|
285
|
-
type: Directive,
|
|
286
|
-
args: [{
|
|
287
|
-
selector: '[formControlState], [ngxControlState]',
|
|
288
|
-
exportAs: 'formControlState, ngxControlState',
|
|
289
|
-
}]
|
|
290
|
-
}], ctorParameters: () => [], propDecorators: { contentNgModel: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModel), { isSignal: true }] }], contentNgModelGroup: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModelGroup), { isSignal: true }] }] } });
|
|
291
|
-
|
|
292
|
-
const SC_ERROR_DISPLAY_MODE_DEFAULT = 'on-blur-or-submit';
|
|
293
|
-
class FormErrorDisplayDirective {
|
|
294
|
-
#formControlState;
|
|
295
|
-
// Optionally inject NgForm for form submission tracking
|
|
296
|
-
#ngForm;
|
|
297
|
-
constructor() {
|
|
298
|
-
this.#formControlState = inject(FormControlStateDirective);
|
|
299
|
-
// Optionally inject NgForm for form submission tracking
|
|
300
|
-
this.#ngForm = inject(NgForm, { optional: true });
|
|
301
|
-
/**
|
|
302
|
-
* Input signal for error display mode.
|
|
303
|
-
* Works seamlessly with hostDirectives in Angular 19+.
|
|
304
|
-
*/
|
|
305
|
-
this.errorDisplayMode = input(inject(NGX_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
306
|
-
inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
307
|
-
SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
|
|
308
|
-
// Expose state signals from FormControlStateDirective
|
|
309
|
-
this.controlState = this.#formControlState.controlState;
|
|
310
|
-
this.errorMessages = this.#formControlState.errorMessages;
|
|
311
|
-
this.warningMessages = this.#formControlState.warningMessages;
|
|
312
|
-
this.hasPendingValidation = this.#formControlState.hasPendingValidation;
|
|
313
|
-
this.isTouched = this.#formControlState.isTouched;
|
|
314
|
-
this.isDirty = this.#formControlState.isDirty;
|
|
315
|
-
this.isValid = this.#formControlState.isValid;
|
|
316
|
-
this.isInvalid = this.#formControlState.isInvalid;
|
|
317
|
-
this.hasBeenValidated = this.#formControlState.hasBeenValidated;
|
|
318
|
-
/**
|
|
319
|
-
* Expose updateOn and formSubmitted as public signals for advanced consumers.
|
|
320
|
-
* updateOn: The ngModelOptions.updateOn value for the control (change/blur/submit)
|
|
321
|
-
* formSubmitted: true after the form is submitted (if NgForm is present)
|
|
322
|
-
*/
|
|
323
|
-
this.updateOn = this.#formControlState.updateOn;
|
|
324
|
-
/**
|
|
325
|
-
* Signal that tracks NgForm.submitted state reactively.
|
|
326
|
-
*
|
|
327
|
-
* Uses toSignal() to convert ngSubmit events + statusChanges into a reactive signal.
|
|
328
|
-
* - ngSubmit: fires when form is submitted (sets NgForm.submitted = true)
|
|
329
|
-
* - statusChanges: fires after resetForm() (which sets NgForm.submitted = false)
|
|
330
|
-
*
|
|
331
|
-
* This ensures proper sync with both submit and reset operations.
|
|
332
|
-
*/
|
|
333
|
-
this.formSubmitted = this.#ngForm
|
|
334
|
-
? toSignal(merge(this.#ngForm.ngSubmit, this.#ngForm.statusChanges ?? of()).pipe(startWith(null), map(() => this.#ngForm?.submitted ?? false)), { initialValue: this.#ngForm.submitted })
|
|
335
|
-
: toSignal(of(false), { initialValue: false });
|
|
336
|
-
/**
|
|
337
|
-
* Determines if errors should be shown based on the specified display mode
|
|
338
|
-
* and the control's state (touched/submitted/validated).
|
|
339
|
-
*
|
|
340
|
-
* Note: We check both hasErrors (extracted error messages) AND isInvalid (Angular's validation state)
|
|
341
|
-
* because in some cases (like conditional validations via validationConfig), the control is marked
|
|
342
|
-
* as invalid by Angular before error messages are extracted from Vest. This ensures aria-invalid
|
|
343
|
-
* is set correctly even during the validation propagation delay.
|
|
344
|
-
*
|
|
345
|
-
* For validationConfig-triggered validations: A field can be validated without being touched
|
|
346
|
-
* (e.g., confirmPassword validated when password changes). We check hasBeenValidated to show
|
|
347
|
-
* errors in these scenarios, providing better UX and proper ARIA attributes.
|
|
348
|
-
*/
|
|
349
|
-
this.shouldShowErrors = computed(() => {
|
|
350
|
-
const mode = this.errorDisplayMode();
|
|
351
|
-
const isTouched = this.isTouched();
|
|
352
|
-
const isInvalid = this.isInvalid();
|
|
353
|
-
const hasErrors = this.errorMessages().length > 0;
|
|
354
|
-
const updateOn = this.updateOn();
|
|
355
|
-
const formSubmitted = this.formSubmitted();
|
|
356
|
-
// Consider errors present if either we have error messages OR the control is invalid
|
|
357
|
-
// This handles the race condition where Angular marks control invalid before Vest errors propagate
|
|
358
|
-
const hasErrorState = hasErrors || isInvalid;
|
|
359
|
-
// Always only show errors after submit if updateOn is 'submit'
|
|
360
|
-
if (updateOn === 'submit') {
|
|
361
|
-
return !!(formSubmitted && hasErrorState);
|
|
362
|
-
}
|
|
363
|
-
// on-blur: show errors after blur (touch)
|
|
364
|
-
if (mode === 'on-blur') {
|
|
365
|
-
return !!(isTouched && hasErrorState);
|
|
366
|
-
}
|
|
367
|
-
// on-submit: show errors after submit
|
|
368
|
-
if (mode === 'on-submit') {
|
|
369
|
-
return !!(formSubmitted && hasErrorState);
|
|
370
|
-
}
|
|
371
|
-
// on-blur-or-submit: show errors after blur (touch) OR submit
|
|
372
|
-
return !!((isTouched || formSubmitted) && hasErrorState);
|
|
373
|
-
}, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
|
|
374
|
-
/**
|
|
375
|
-
* Errors to display (filtered for pending state)
|
|
376
|
-
*/
|
|
377
|
-
this.errors = computed(() => {
|
|
378
|
-
if (this.hasPendingValidation())
|
|
379
|
-
return [];
|
|
380
|
-
return this.errorMessages();
|
|
381
|
-
}, ...(ngDevMode ? [{ debugName: "errors" }] : []));
|
|
382
|
-
/**
|
|
383
|
-
* Warnings to display (filtered for pending state)
|
|
384
|
-
*/
|
|
385
|
-
this.warnings = computed(() => {
|
|
386
|
-
if (this.hasPendingValidation())
|
|
387
|
-
return [];
|
|
388
|
-
return this.warningMessages();
|
|
389
|
-
}, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
|
|
390
|
-
/**
|
|
391
|
-
* Whether the control is currently being validated (pending)
|
|
392
|
-
* Excludes pristine+untouched controls to prevent "Validating..." on initial load
|
|
393
|
-
*/
|
|
394
|
-
this.isPending = computed(() => {
|
|
395
|
-
// Don't show pending state for pristine untouched controls
|
|
396
|
-
// This prevents "Validating..." message appearing on initial page load
|
|
397
|
-
const state = this.#formControlState.controlState();
|
|
398
|
-
if (state.isPristine && !state.isTouched) {
|
|
399
|
-
return false;
|
|
400
|
-
}
|
|
401
|
-
return this.hasPendingValidation();
|
|
402
|
-
}, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
403
|
-
// Warn about problematic combinations of updateOn and errorDisplayMode
|
|
404
|
-
effect(() => {
|
|
405
|
-
const mode = this.errorDisplayMode();
|
|
406
|
-
const updateOn = this.updateOn();
|
|
407
|
-
if (updateOn === 'submit' && mode === 'on-blur') {
|
|
408
|
-
console.warn('[ngx-vest-forms] Potential UX issue: errorDisplayMode is "on-blur" but updateOn is "submit". Errors will only show after form submission, not after blur.');
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
413
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: FormErrorDisplayDirective, isStandalone: true, selector: "[formErrorDisplay], [ngxErrorDisplay]", inputs: { errorDisplayMode: { classPropertyName: "errorDisplayMode", publicName: "errorDisplayMode", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["formErrorDisplay", "ngxErrorDisplay"], hostDirectives: [{ directive: FormControlStateDirective }], ngImport: i0 }); }
|
|
414
|
-
}
|
|
415
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
|
|
416
|
-
type: Directive,
|
|
417
|
-
args: [{
|
|
418
|
-
selector: '[formErrorDisplay], [ngxErrorDisplay]',
|
|
419
|
-
exportAs: 'formErrorDisplay, ngxErrorDisplay',
|
|
420
|
-
hostDirectives: [FormControlStateDirective],
|
|
421
|
-
}]
|
|
422
|
-
}], ctorParameters: () => [], propDecorators: { errorDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorDisplayMode", required: false }] }] } });
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Creates a debounced pending state signal that prevents flashing validation messages.
|
|
426
|
-
*
|
|
427
|
-
* This utility helps implement a better UX for async validations by:
|
|
428
|
-
* 1. Delaying the display of "Validating..." messages until validation takes longer than `showAfter`ms (default: 200ms)
|
|
429
|
-
* 2. Keeping the message visible for at least `minimumDisplay`ms (default: 500ms) once shown to prevent flickering
|
|
430
|
-
*
|
|
431
|
-
* @example
|
|
432
|
-
* ```typescript
|
|
433
|
-
* @Component({
|
|
434
|
-
* template: `
|
|
435
|
-
* @if (showPendingMessage()) {
|
|
436
|
-
* <div role="status" aria-live="polite">Validating…</div>
|
|
437
|
-
* }
|
|
438
|
-
* `
|
|
439
|
-
* })
|
|
440
|
-
* export class CustomControlWrapperComponent {
|
|
441
|
-
* protected readonly errorDisplay = inject(FormErrorDisplayDirective, { self: true });
|
|
442
|
-
*
|
|
443
|
-
* // Create debounced pending state
|
|
444
|
-
* private readonly pendingState = createDebouncedPendingState(
|
|
445
|
-
* this.errorDisplay.isPending,
|
|
446
|
-
* { showAfter: 200, minimumDisplay: 500 }
|
|
447
|
-
* );
|
|
448
|
-
*
|
|
449
|
-
* protected readonly showPendingMessage = this.pendingState.showPendingMessage;
|
|
450
|
-
* }
|
|
451
|
-
* ```
|
|
452
|
-
*
|
|
453
|
-
* @param isPending - Signal indicating whether async validation is currently pending
|
|
454
|
-
* @param options - Configuration options for debouncing behavior
|
|
455
|
-
* @returns Object containing the debounced showPendingMessage signal and cleanup function
|
|
456
|
-
*/
|
|
457
|
-
function createDebouncedPendingState(isPending, options = {}) {
|
|
458
|
-
const { showAfter = 200, minimumDisplay = 500 } = options;
|
|
459
|
-
// Create writable signal for debounced state
|
|
460
|
-
const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : []));
|
|
461
|
-
// Track timeouts
|
|
462
|
-
let pendingTimeout = null;
|
|
463
|
-
let minimumDisplayTimeout = null;
|
|
464
|
-
// Cleanup function
|
|
465
|
-
const cleanup = () => {
|
|
466
|
-
if (pendingTimeout) {
|
|
467
|
-
clearTimeout(pendingTimeout);
|
|
468
|
-
pendingTimeout = null;
|
|
469
|
-
}
|
|
470
|
-
if (minimumDisplayTimeout) {
|
|
471
|
-
clearTimeout(minimumDisplayTimeout);
|
|
472
|
-
minimumDisplayTimeout = null;
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
// Effect to manage debounced pending message display
|
|
476
|
-
effect((onCleanup) => {
|
|
477
|
-
const pending = isPending();
|
|
478
|
-
if (pending) {
|
|
479
|
-
// Clear any existing minimum display timeout
|
|
480
|
-
if (minimumDisplayTimeout) {
|
|
481
|
-
clearTimeout(minimumDisplayTimeout);
|
|
482
|
-
minimumDisplayTimeout = null;
|
|
483
|
-
}
|
|
484
|
-
// Start delay timer before showing pending message
|
|
485
|
-
pendingTimeout = setTimeout(() => {
|
|
486
|
-
showPendingMessageSignal.set(true);
|
|
487
|
-
pendingTimeout = null;
|
|
488
|
-
}, showAfter);
|
|
489
|
-
onCleanup(() => {
|
|
490
|
-
if (pendingTimeout) {
|
|
491
|
-
clearTimeout(pendingTimeout);
|
|
492
|
-
pendingTimeout = null;
|
|
493
|
-
}
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
else {
|
|
497
|
-
// Validation completed
|
|
498
|
-
if (pendingTimeout) {
|
|
499
|
-
// Validation completed before delay - don't show message
|
|
500
|
-
clearTimeout(pendingTimeout);
|
|
501
|
-
pendingTimeout = null;
|
|
502
|
-
}
|
|
503
|
-
else if (showPendingMessageSignal()) {
|
|
504
|
-
// Message was shown - keep it visible for minimum duration
|
|
505
|
-
minimumDisplayTimeout = setTimeout(() => {
|
|
506
|
-
showPendingMessageSignal.set(false);
|
|
507
|
-
minimumDisplayTimeout = null;
|
|
508
|
-
}, minimumDisplay);
|
|
509
|
-
onCleanup(() => {
|
|
510
|
-
if (minimumDisplayTimeout) {
|
|
511
|
-
clearTimeout(minimumDisplayTimeout);
|
|
512
|
-
minimumDisplayTimeout = null;
|
|
513
|
-
}
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
return {
|
|
519
|
-
showPendingMessage: showPendingMessageSignal.asReadonly(),
|
|
520
|
-
cleanup,
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Counter for unique IDs
|
|
525
|
-
let nextUniqueId$2 = 0;
|
|
526
|
-
/**
|
|
527
|
-
* Accessible form control wrapper with WCAG 2.2 AA compliance.
|
|
528
|
-
*
|
|
529
|
-
* Wrap form fields to automatically display validation errors, warnings, and pending states
|
|
530
|
-
* with proper accessibility attributes.
|
|
531
|
-
*
|
|
532
|
-
* @usageNotes
|
|
533
|
-
*
|
|
534
|
-
* ### Basic Usage
|
|
535
|
-
* ```html
|
|
536
|
-
* <ngx-control-wrapper>
|
|
537
|
-
* <label for="email">Email</label>
|
|
538
|
-
* <input id="email" name="email" [ngModel]="formValue().email" />
|
|
539
|
-
* </ngx-control-wrapper>
|
|
540
|
-
* ```
|
|
541
|
-
*
|
|
542
|
-
* ### Error Display Modes
|
|
543
|
-
* Control when errors appear using the `errorDisplayMode` input:
|
|
544
|
-
* - `'on-blur-or-submit'` (default): Show errors after blur OR form submit
|
|
545
|
-
* - `'on-blur'`: Show errors only after blur
|
|
546
|
-
* - `'on-submit'`: Show errors only after form submit
|
|
547
|
-
*
|
|
548
|
-
* ```html
|
|
549
|
-
* <ngx-control-wrapper [errorDisplayMode]="'on-submit'">
|
|
550
|
-
* <input name="email" [ngModel]="formValue().email" />
|
|
551
|
-
* </ngx-control-wrapper>
|
|
552
|
-
* ```
|
|
553
|
-
*
|
|
554
|
-
* ### Accessibility Features (Automatic)
|
|
555
|
-
* - Unique IDs for error/warning/pending regions
|
|
556
|
-
* - `aria-describedby` linking errors to form controls
|
|
557
|
-
* - `aria-invalid="true"` when errors are shown
|
|
558
|
-
* - `role="status"` with `aria-live="polite"` for non-interruptive announcements
|
|
559
|
-
* - Debounced pending state to prevent flashing for quick validations
|
|
560
|
-
*
|
|
561
|
-
* @see {@link FormErrorDisplayDirective} for custom wrapper implementation
|
|
562
|
-
*
|
|
563
|
-
* Blocking Errors (form-level):
|
|
564
|
-
* - For post-submit validation errors that block submission, implement a separate
|
|
565
|
-
* form-level error summary with role="alert" and aria-live="assertive"
|
|
566
|
-
* - Example:
|
|
567
|
-
* ```html
|
|
568
|
-
* <!-- Keep in DOM; update text content on submit -->
|
|
569
|
-
* <div id="form-errors" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
|
570
|
-
* ```
|
|
571
|
-
* - This provides immediate, reliable announcements for blocking errors while keeping
|
|
572
|
-
* inline field errors non-disruptive. Follows WCAG ARIA19/ARIA22 guidance.
|
|
573
|
-
*
|
|
574
|
-
* Error & Warning Display Behavior:
|
|
575
|
-
* - The error display mode can be configured globally using the SC_ERROR_DISPLAY_MODE_TOKEN injection token (import from core), or per instance using the `errorDisplayMode` input on FormErrorDisplayDirective (which this component uses as a hostDirective).
|
|
576
|
-
* - Possible values: 'on-blur' | 'on-submit' | 'on-blur-or-submit' (default: 'on-blur-or-submit')
|
|
577
|
-
*
|
|
578
|
-
* Example (per instance):
|
|
579
|
-
* <div ngxControlWrapper>
|
|
580
|
-
* <label>
|
|
581
|
-
* <span>First name</span>
|
|
582
|
-
* <input type="text" name="firstName" [ngModel]="formValue().firstName" />
|
|
583
|
-
* </label>
|
|
584
|
-
* </div>
|
|
585
|
-
* /// To customize errorDisplayMode for this instance, use the errorDisplayMode input.
|
|
586
|
-
*
|
|
587
|
-
* Example (with warnings and pending):
|
|
588
|
-
* <ngx-control-wrapper>
|
|
589
|
-
* <input name="username" ngModel />
|
|
590
|
-
* </ngx-control-wrapper>
|
|
591
|
-
* /// If async validation is running for >200ms, a spinner and 'Validating…' will be shown.
|
|
592
|
-
* /// Once shown, the validation message stays visible for minimum 500ms to prevent flashing.
|
|
593
|
-
* /// If Vest warnings are present, they will be shown below errors.
|
|
594
|
-
*
|
|
595
|
-
* Example (global config):
|
|
596
|
-
* import { provide } from '@angular/core';
|
|
597
|
-
* import { SC_ERROR_DISPLAY_MODE_TOKEN } from 'ngx-vest-forms';
|
|
598
|
-
* @Component({
|
|
599
|
-
* providers: [
|
|
600
|
-
* provide(SC_ERROR_DISPLAY_MODE_TOKEN, { useValue: 'submit' })
|
|
601
|
-
* ]
|
|
602
|
-
* })
|
|
603
|
-
* export class MyComponent {}
|
|
604
|
-
*
|
|
605
|
-
* Best Practices:
|
|
606
|
-
* - Use for every input or group in your forms.
|
|
607
|
-
* - Do not manually display errors for individual fields; rely on this wrapper.
|
|
608
|
-
* - Validate with tools like Accessibility Insights and real screen reader testing.
|
|
609
|
-
*
|
|
610
|
-
* @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA19 - ARIA19: Using ARIA role=alert
|
|
611
|
-
* @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - ARIA22: Using role=status
|
|
612
|
-
*/
|
|
613
|
-
class ControlWrapperComponent {
|
|
614
|
-
mergeAriaDescribedBy(existing, wrapperActiveIds) {
|
|
615
|
-
const existingTokens = (existing ?? '')
|
|
616
|
-
.split(/\s+/)
|
|
617
|
-
.map((t) => t.trim())
|
|
618
|
-
.filter(Boolean);
|
|
619
|
-
// Remove any previous wrapper-owned IDs from the existing list.
|
|
620
|
-
const existingWithoutWrapper = existingTokens.filter((t) => !this.wrapperOwnedDescribedByIds.includes(t));
|
|
621
|
-
// Append current wrapper IDs, preserving existing order and uniqueness.
|
|
622
|
-
const merged = [...existingWithoutWrapper];
|
|
623
|
-
for (const id of wrapperActiveIds) {
|
|
624
|
-
if (!merged.includes(id)) {
|
|
625
|
-
merged.push(id);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
return merged.length > 0 ? merged.join(' ') : null;
|
|
629
|
-
}
|
|
630
|
-
constructor() {
|
|
631
|
-
this.errorDisplay = inject(FormErrorDisplayDirective, {
|
|
632
|
-
self: true,
|
|
633
|
-
});
|
|
634
|
-
this.elementRef = inject(ElementRef);
|
|
635
|
-
this.destroyRef = inject(DestroyRef);
|
|
636
|
-
/**
|
|
637
|
-
* Controls how this wrapper applies ARIA attributes to descendant controls.
|
|
638
|
-
*
|
|
639
|
-
* - `all-controls` (default, backwards compatible): apply `aria-describedby` / `aria-invalid`
|
|
640
|
-
* to all `input/select/textarea` elements inside the wrapper.
|
|
641
|
-
* - `single-control`: apply ARIA attributes only when exactly one control is found.
|
|
642
|
-
* (Useful for wrappers that sometimes contain helper buttons/controls.)
|
|
643
|
-
* - `none`: do not mutate descendant controls at all (group-safe mode).
|
|
644
|
-
*
|
|
645
|
-
* Notes:
|
|
646
|
-
* - Use `none` when wrapping a container (e.g. `NgModelGroup`) to avoid stamping ARIA
|
|
647
|
-
* across multiple child controls.
|
|
648
|
-
* - This does not affect whether messages render; it only affects ARIA wiring.
|
|
649
|
-
*/
|
|
650
|
-
this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
|
|
651
|
-
// Generate unique IDs for ARIA associations
|
|
652
|
-
this.uniqueId = `ngx-control-wrapper-${nextUniqueId$2++}`;
|
|
653
|
-
this.errorId = `${this.uniqueId}-error`;
|
|
654
|
-
this.warningId = `${this.uniqueId}-warning`;
|
|
655
|
-
this.pendingId = `${this.uniqueId}-pending`;
|
|
656
|
-
// Track form controls found in the wrapper
|
|
657
|
-
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
|
|
658
|
-
// Signals when content is initialized so effects can safely touch the DOM.
|
|
659
|
-
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
|
|
660
|
-
// MutationObserver to detect dynamically added/removed controls
|
|
661
|
-
this.mutationObserver = null;
|
|
662
|
-
/**
|
|
663
|
-
* Debounced pending state to prevent flashing for quick async validations.
|
|
664
|
-
* Uses createDebouncedPendingState utility with 500ms delay and 500ms minimum display.
|
|
665
|
-
*/
|
|
666
|
-
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
|
|
667
|
-
this.showPendingMessage = this.pendingState.showPendingMessage;
|
|
668
|
-
/**
|
|
669
|
-
* Computed signal that builds aria-describedby string based on visible regions
|
|
670
|
-
*/
|
|
671
|
-
this.ariaDescribedBy = computed(() => {
|
|
672
|
-
const ids = [];
|
|
673
|
-
if (this.errorDisplay.shouldShowErrors()) {
|
|
674
|
-
ids.push(this.errorId);
|
|
675
|
-
}
|
|
676
|
-
if (this.errorDisplay.warnings().length > 0) {
|
|
677
|
-
ids.push(this.warningId);
|
|
678
|
-
}
|
|
679
|
-
if (this.showPendingMessage()) {
|
|
680
|
-
ids.push(this.pendingId);
|
|
681
|
-
}
|
|
682
|
-
return ids.length > 0 ? ids.join(' ') : null;
|
|
683
|
-
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
|
|
684
|
-
/**
|
|
685
|
-
* IDs managed by this wrapper when composing aria-describedby.
|
|
686
|
-
*
|
|
687
|
-
* We remove only these from the consumer-provided aria-describedby tokens and then
|
|
688
|
-
* append the currently-relevant wrapper IDs. This prevents clobbering app-provided
|
|
689
|
-
* hint/help text associations.
|
|
690
|
-
*/
|
|
691
|
-
this.wrapperOwnedDescribedByIds = [
|
|
692
|
-
this.errorId,
|
|
693
|
-
this.warningId,
|
|
694
|
-
this.pendingId,
|
|
695
|
-
];
|
|
696
|
-
// Effect to update aria-describedby and aria-invalid on form controls
|
|
697
|
-
effect(() => {
|
|
698
|
-
if (!this.contentInitialized())
|
|
699
|
-
return;
|
|
700
|
-
const mode = this.ariaAssociationMode();
|
|
701
|
-
if (mode === 'none') {
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
const describedBy = this.ariaDescribedBy();
|
|
705
|
-
const wrapperActiveIds = describedBy
|
|
706
|
-
? describedBy.split(/\s+/).filter(Boolean)
|
|
707
|
-
: [];
|
|
708
|
-
const shouldShowErrors = this.errorDisplay.shouldShowErrors();
|
|
709
|
-
const targets = (() => {
|
|
710
|
-
const controls = this.formControls();
|
|
711
|
-
if (mode === 'single-control') {
|
|
712
|
-
return controls.length === 1 ? controls : [];
|
|
713
|
-
}
|
|
714
|
-
return controls;
|
|
715
|
-
})();
|
|
716
|
-
targets.forEach((control) => {
|
|
717
|
-
// Update aria-describedby (merge, don't overwrite)
|
|
718
|
-
const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), wrapperActiveIds);
|
|
719
|
-
if (nextDescribedBy) {
|
|
720
|
-
control.setAttribute('aria-describedby', nextDescribedBy);
|
|
721
|
-
}
|
|
722
|
-
else {
|
|
723
|
-
control.removeAttribute('aria-describedby');
|
|
724
|
-
}
|
|
725
|
-
// Update aria-invalid
|
|
726
|
-
if (shouldShowErrors) {
|
|
727
|
-
control.setAttribute('aria-invalid', 'true');
|
|
728
|
-
}
|
|
729
|
-
else {
|
|
730
|
-
control.removeAttribute('aria-invalid');
|
|
731
|
-
}
|
|
732
|
-
});
|
|
733
|
-
});
|
|
734
|
-
// Clean up MutationObserver when component is destroyed
|
|
735
|
-
this.destroyRef.onDestroy(() => {
|
|
736
|
-
this.mutationObserver?.disconnect();
|
|
737
|
-
this.mutationObserver = null;
|
|
738
|
-
});
|
|
739
|
-
// Effect to enable/disable DOM observation based on ariaAssociationMode.
|
|
740
|
-
// This keeps the wrapper cheap in group-safe mode.
|
|
741
|
-
effect(() => {
|
|
742
|
-
if (!this.contentInitialized())
|
|
743
|
-
return;
|
|
744
|
-
const mode = this.ariaAssociationMode();
|
|
745
|
-
if (mode === 'none') {
|
|
746
|
-
this.mutationObserver?.disconnect();
|
|
747
|
-
this.mutationObserver = null;
|
|
748
|
-
if (this.formControls().length > 0) {
|
|
749
|
-
this.formControls.set([]);
|
|
750
|
-
}
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
// Ensure controls list is up to date.
|
|
754
|
-
this.updateFormControls();
|
|
755
|
-
// Ensure MutationObserver is installed (dynamic @if/@for support).
|
|
756
|
-
if (!this.mutationObserver) {
|
|
757
|
-
this.mutationObserver = new MutationObserver(() => {
|
|
758
|
-
this.updateFormControls();
|
|
759
|
-
});
|
|
760
|
-
this.mutationObserver.observe(this.elementRef.nativeElement, {
|
|
761
|
-
childList: true,
|
|
762
|
-
subtree: true,
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
ngAfterContentInit() {
|
|
768
|
-
this.contentInitialized.set(true);
|
|
769
|
-
// ARIA wiring + observer setup is managed by effects so that the wrapper can
|
|
770
|
-
// opt out (ariaAssociationMode="none").
|
|
771
|
-
}
|
|
772
|
-
ngOnDestroy() {
|
|
773
|
-
this.mutationObserver?.disconnect();
|
|
774
|
-
this.mutationObserver = null;
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Query and update the list of form controls within this wrapper.
|
|
778
|
-
* Called on init and whenever the DOM structure changes.
|
|
779
|
-
*/
|
|
780
|
-
updateFormControls() {
|
|
781
|
-
const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
|
|
782
|
-
this.formControls.set(Array.from(controls));
|
|
783
|
-
}
|
|
784
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
785
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", 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"] }], ngImport: i0, template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Live regions are kept stable in the DOM to improve announcement reliability.\n We update the inner content instead of relying on node mount/unmount.\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>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error.message || error }}</li>\n }\n </ul>\n }\n</div>\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 (errorDisplay.warnings().length > 0) {\n <ul>\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 }); }
|
|
22
|
+
/**
|
|
23
|
+
* Injection token for configuring the default warning display mode.
|
|
24
|
+
* Values: 'on-touch' | 'on-validated-or-touch' (default)
|
|
25
|
+
*/
|
|
26
|
+
const NGX_WARNING_DISPLAY_MODE_TOKEN = new InjectionToken('NGX_WARNING_DISPLAY_MODE_TOKEN', {
|
|
27
|
+
providedIn: 'root',
|
|
28
|
+
factory: () => 'on-validated-or-touch',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const NGX_VEST_FORMS_ERRORS = {
|
|
32
|
+
EXTRA_PROPERTY: {
|
|
33
|
+
code: 'NGX-001',
|
|
34
|
+
message: (path) => `Shape mismatch: Property '${path}' is present in the form value but not defined in the form shape.`,
|
|
35
|
+
},
|
|
36
|
+
TYPE_MISMATCH: {
|
|
37
|
+
code: 'NGX-002',
|
|
38
|
+
message: (path, expected, actual) => `Type mismatch at '${path}': Expected '${expected}' but got '${actual}'.`,
|
|
39
|
+
},
|
|
40
|
+
CONTROL_NOT_FOUND: {
|
|
41
|
+
code: 'NGX-003',
|
|
42
|
+
message: (path) => `Control not found: Could not find form control at path '${path}'. Check your [ngModel] name attributes.`,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
function logWarning(error, ...args) {
|
|
46
|
+
console.warn(`[${error.code}] ${error.message(...args)}\nCheck your [formShape] input and the initial [formValue].`);
|
|
786
47
|
}
|
|
787
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ControlWrapperComponent, decorators: [{
|
|
788
|
-
type: Component,
|
|
789
|
-
args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
790
|
-
class: 'ngx-control-wrapper sc-control-wrapper',
|
|
791
|
-
'[class.ngx-control-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
|
|
792
|
-
'[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
|
|
793
|
-
}, hostDirectives: [
|
|
794
|
-
{
|
|
795
|
-
directive: FormErrorDisplayDirective,
|
|
796
|
-
inputs: ['errorDisplayMode'],
|
|
797
|
-
},
|
|
798
|
-
], template: "<div class=\"ngx-control-wrapper__content\">\n <ng-content />\n</div>\n\n<!--\n Live regions are kept stable in the DOM to improve announcement reliability.\n We update the inner content instead of relying on node mount/unmount.\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>\n @for (error of errorDisplay.errors(); track error) {\n <li>{{ error.message || error }}</li>\n }\n </ul>\n }\n</div>\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 (errorDisplay.warnings().length > 0) {\n <ul>\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"] }]
|
|
799
|
-
}], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
|
|
800
48
|
|
|
801
|
-
let nextUniqueId$1 = 0;
|
|
802
49
|
/**
|
|
803
|
-
*
|
|
50
|
+
* Injection token for configurable validation config debounce timing.
|
|
804
51
|
*
|
|
805
|
-
* This
|
|
806
|
-
*
|
|
52
|
+
* This token allows you to configure the debounce time for validation config
|
|
53
|
+
* dependencies at the application, route, or component level.
|
|
807
54
|
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* /// Global configuration
|
|
58
|
+
* export const appConfig: ApplicationConfig = {
|
|
59
|
+
* providers: [
|
|
60
|
+
* {
|
|
61
|
+
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
|
|
62
|
+
* useValue: 200
|
|
63
|
+
* }
|
|
64
|
+
* ]
|
|
65
|
+
* };
|
|
66
|
+
*
|
|
67
|
+
* /// Per-route configuration
|
|
68
|
+
* {
|
|
69
|
+
* path: 'checkout',
|
|
70
|
+
* component: CheckoutComponent,
|
|
71
|
+
* providers: [
|
|
72
|
+
* {
|
|
73
|
+
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
|
|
74
|
+
* useValue: 50
|
|
75
|
+
* }
|
|
76
|
+
* ]
|
|
77
|
+
* }
|
|
78
|
+
*
|
|
79
|
+
* /// Per-component override
|
|
80
|
+
* @Component({
|
|
81
|
+
* providers: [
|
|
82
|
+
* {
|
|
83
|
+
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
|
|
84
|
+
* useValue: 0 // for testing
|
|
85
|
+
* }
|
|
86
|
+
* ]
|
|
87
|
+
* })
|
|
88
|
+
* export class TestFormComponent {}
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @default 100ms - Maintains backward compatibility with existing behavior
|
|
810
92
|
*/
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
93
|
+
const NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN = new InjectionToken('NgxValidationConfigDebounceTime', {
|
|
94
|
+
providedIn: 'root',
|
|
95
|
+
factory: () => 100,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Type guard to check if a value is a primitive type.
|
|
100
|
+
*
|
|
101
|
+
* @param value - The value to check
|
|
102
|
+
* @returns true if the value is a primitive (string, number, boolean, null, undefined, symbol, bigint)
|
|
103
|
+
*/
|
|
104
|
+
function isPrimitive$1(value) {
|
|
105
|
+
return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* @internal
|
|
109
|
+
* Internal utility for shallow equality checks.
|
|
110
|
+
*
|
|
111
|
+
* **Not intended for external use.** This function is used internally by the library
|
|
112
|
+
* for performance-critical operations. Consider using your own comparison logic or
|
|
113
|
+
* a library like lodash if you need shallow equality checks in your application.
|
|
114
|
+
*
|
|
115
|
+
* Optimized shallow equality check for objects.
|
|
116
|
+
*
|
|
117
|
+
* **Why this custom implementation is preferred:**
|
|
118
|
+
* - **Performance**: Direct property comparison is significantly faster than JSON.stringify
|
|
119
|
+
* - **Type Safety**: Handles null/undefined values correctly without serialization issues
|
|
120
|
+
* - **Accuracy**: Doesn't suffer from JSON.stringify limitations (undefined values, functions, symbols)
|
|
121
|
+
* - **Memory Efficient**: No temporary string creation or object serialization overhead
|
|
122
|
+
*
|
|
123
|
+
* **Use Cases:**
|
|
124
|
+
* - Form value change detection where only top-level properties matter
|
|
125
|
+
* - Quick object comparison in performance-critical code paths
|
|
126
|
+
* - Validation triggers where deep comparison is unnecessary
|
|
127
|
+
*
|
|
128
|
+
* **Performance Comparison:**
|
|
129
|
+
* ```typescript
|
|
130
|
+
* /// ❌ Slow: JSON.stringify approach
|
|
131
|
+
* JSON.stringify(obj1) === JSON.stringify(obj2)
|
|
132
|
+
*
|
|
133
|
+
* /// ✅ Fast: Direct property comparison
|
|
134
|
+
* shallowEqual(obj1, obj2)
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* @param obj1 - First object to compare
|
|
138
|
+
* @param obj2 - Second object to compare
|
|
139
|
+
* @returns true if objects are shallowly equal (same keys and same values by reference)
|
|
140
|
+
*/
|
|
141
|
+
function shallowEqual(obj1, obj2) {
|
|
142
|
+
if (obj1 === obj2) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (obj1 == null || obj2 == null) {
|
|
146
|
+
return obj1 === obj2;
|
|
147
|
+
}
|
|
148
|
+
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
|
|
149
|
+
return obj1 === obj2;
|
|
150
|
+
}
|
|
151
|
+
const keys1 = Object.keys(obj1);
|
|
152
|
+
const keys2 = Object.keys(obj2);
|
|
153
|
+
if (keys1.length !== keys2.length) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
for (const key of keys1) {
|
|
157
|
+
if (!Object.hasOwn(obj2, key) ||
|
|
158
|
+
obj1[key] !==
|
|
159
|
+
obj2[key]) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
846
162
|
}
|
|
847
|
-
|
|
848
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]", 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: ["formGroupWrapper", "ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], 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.warnings().length > 0) {\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 }); }
|
|
163
|
+
return true;
|
|
849
164
|
}
|
|
850
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
|
|
851
|
-
type: Component,
|
|
852
|
-
args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]', exportAs: 'formGroupWrapper, ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
853
|
-
class: 'ngx-form-group-wrapper sc-form-group-wrapper',
|
|
854
|
-
'[class.ngx-form-group-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
|
|
855
|
-
'[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
|
|
856
|
-
}, hostDirectives: [
|
|
857
|
-
{
|
|
858
|
-
directive: FormErrorDisplayDirective,
|
|
859
|
-
inputs: ['errorDisplayMode'],
|
|
860
|
-
},
|
|
861
|
-
], 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.warnings().length > 0) {\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"] }]
|
|
862
|
-
}], propDecorators: { pendingDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingDebounce", required: false }] }] } });
|
|
863
|
-
|
|
864
|
-
let nextUniqueId = 0;
|
|
865
165
|
/**
|
|
866
|
-
*
|
|
166
|
+
* @internal
|
|
167
|
+
* Internal utility for deep equality checks.
|
|
867
168
|
*
|
|
868
|
-
* This
|
|
869
|
-
*
|
|
870
|
-
*
|
|
169
|
+
* **Not intended for external use.** This function is used internally by the library
|
|
170
|
+
* for form value comparison and change detection. Consider using your own comparison
|
|
171
|
+
* logic or a library like lodash if you need deep equality checks in your application.
|
|
871
172
|
*
|
|
872
|
-
*
|
|
173
|
+
* Fast deep equality check optimized for form values and Angular applications.
|
|
174
|
+
*
|
|
175
|
+
* **Why this custom implementation is preferred over alternatives:**
|
|
176
|
+
*
|
|
177
|
+
* **vs JSON.stringify():**
|
|
178
|
+
* - **10-100x faster**: Direct comparison without string serialization overhead
|
|
179
|
+
* - **Accurate**: Handles Date objects, RegExp, undefined values, and functions correctly
|
|
180
|
+
* - **Memory efficient**: No temporary string creation or garbage collection pressure
|
|
181
|
+
* - **Preserves semantics**: Maintains type information during comparison
|
|
182
|
+
*
|
|
183
|
+
* **vs structuredClone():**
|
|
184
|
+
* - **Wrong purpose**: structuredClone creates copies, not comparisons
|
|
185
|
+
* - **Performance**: Would require cloning both objects just to compare them
|
|
186
|
+
* - **Memory waste**: Creates unnecessary deep copies, doubling memory usage
|
|
187
|
+
* - **Still incomplete**: Even after cloning, you'd still need a comparison function
|
|
188
|
+
*
|
|
189
|
+
* **vs External libraries (lodash.isEqual, etc.):**
|
|
190
|
+
* - **Bundle size**: Zero dependencies, smaller application bundles
|
|
191
|
+
* - **Form-specific**: Optimized for common Angular form data patterns
|
|
192
|
+
* - **Type safety**: Full TypeScript integration with strict typing
|
|
193
|
+
* - **Performance**: Tailored algorithms for form value comparison use cases
|
|
194
|
+
*
|
|
195
|
+
* **Supported Data Types:**
|
|
196
|
+
* - Primitives (string, number, boolean, null, undefined, symbol, bigint)
|
|
197
|
+
* - Arrays (with recursive deep comparison)
|
|
198
|
+
* - Plain objects (with recursive deep comparison)
|
|
199
|
+
* - Date objects (by timestamp comparison)
|
|
200
|
+
* - RegExp objects (by source and flags comparison)
|
|
201
|
+
* - Set objects (by size and value membership)
|
|
202
|
+
* - Map objects (by size and key-value pairs)
|
|
203
|
+
*
|
|
204
|
+
* **Safety Features:**
|
|
205
|
+
* - **Circular reference protection**: MaxDepth parameter prevents infinite recursion
|
|
206
|
+
* - **Type coercion prevention**: Strict type checking before comparison
|
|
207
|
+
* - **Null safety**: Proper handling of null and undefined values
|
|
208
|
+
*
|
|
209
|
+
* **Performance Characteristics:**
|
|
210
|
+
* ```typescript
|
|
211
|
+
* /// Performance comparison on typical form objects:
|
|
212
|
+
* /// JSON.stringify: ~100ms for complex nested forms
|
|
213
|
+
* /// fastDeepEqual: ~1-5ms for the same objects
|
|
214
|
+
* ///
|
|
215
|
+
* /// Memory usage:
|
|
216
|
+
* /// JSON.stringify: Creates temporary strings (high GC pressure)
|
|
217
|
+
* /// fastDeepEqual: Zero allocations during comparison
|
|
218
|
+
* ```
|
|
219
|
+
*
|
|
220
|
+
* **Typical Usage in Forms:**
|
|
221
|
+
* ```typescript
|
|
222
|
+
* /// Detect when form values actually change
|
|
223
|
+
* distinctUntilChanged(fastDeepEqual)
|
|
224
|
+
*
|
|
225
|
+
* /// Prevent unnecessary re-renders
|
|
226
|
+
* if (!fastDeepEqual(oldFormValue, newFormValue)) {
|
|
227
|
+
* updateUI();
|
|
228
|
+
* }
|
|
229
|
+
* ```
|
|
230
|
+
*
|
|
231
|
+
* @param obj1 - First object to compare
|
|
232
|
+
* @param obj2 - Second object to compare
|
|
233
|
+
* @param maxDepth - Maximum recursion depth to prevent infinite loops (default: 10)
|
|
234
|
+
* @returns true if objects are deeply equal by value
|
|
873
235
|
*/
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
236
|
+
function fastDeepEqual(obj1, obj2, maxDepth = 10) {
|
|
237
|
+
if (maxDepth <= 0) {
|
|
238
|
+
// Fallback to shallow comparison at max depth to prevent infinite recursion
|
|
239
|
+
return obj1 === obj2;
|
|
240
|
+
}
|
|
241
|
+
if (obj1 === obj2) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (obj1 == null || obj2 == null) {
|
|
245
|
+
return obj1 === obj2;
|
|
246
|
+
}
|
|
247
|
+
if (typeof obj1 !== typeof obj2) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
if (isPrimitive$1(obj1) || isPrimitive$1(obj2)) {
|
|
251
|
+
return obj1 === obj2;
|
|
252
|
+
}
|
|
253
|
+
// Handle arrays early for performance (common in forms)
|
|
254
|
+
if (Array.isArray(obj1) !== Array.isArray(obj2)) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(obj1)) {
|
|
258
|
+
// We know obj2 is also an array here
|
|
259
|
+
const arr2 = obj2;
|
|
260
|
+
if (obj1.length !== arr2.length) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
for (let i = 0; i < obj1.length; i++) {
|
|
264
|
+
if (!fastDeepEqual(obj1[i], arr2[i], maxDepth - 1)) {
|
|
265
|
+
return false;
|
|
885
266
|
}
|
|
886
267
|
}
|
|
887
|
-
return
|
|
268
|
+
return true;
|
|
888
269
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
this.uniqueId = `ngx-error-control-${nextUniqueId++}`;
|
|
908
|
-
this.errorId = `${this.uniqueId}-error`;
|
|
909
|
-
this.warningId = `${this.uniqueId}-warning`;
|
|
910
|
-
this.pendingId = `${this.uniqueId}-pending`;
|
|
911
|
-
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
|
|
912
|
-
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
|
|
913
|
-
this.mutationObserver = null;
|
|
914
|
-
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
|
|
915
|
-
this.showPendingMessage = this.pendingState.showPendingMessage;
|
|
916
|
-
/**
|
|
917
|
-
* aria-describedby value representing the *currently relevant* message regions.
|
|
918
|
-
*/
|
|
919
|
-
this.ariaDescribedBy = computed(() => {
|
|
920
|
-
const ids = [];
|
|
921
|
-
if (this.errorDisplay.shouldShowErrors()) {
|
|
922
|
-
ids.push(this.errorId);
|
|
923
|
-
}
|
|
924
|
-
if (this.errorDisplay.warnings().length > 0) {
|
|
925
|
-
ids.push(this.warningId);
|
|
926
|
-
}
|
|
927
|
-
if (this.showPendingMessage()) {
|
|
928
|
-
ids.push(this.pendingId);
|
|
929
|
-
}
|
|
930
|
-
return ids.length > 0 ? ids.join(' ') : null;
|
|
931
|
-
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
|
|
932
|
-
this.ownedDescribedByIds = [
|
|
933
|
-
this.errorId,
|
|
934
|
-
this.warningId,
|
|
935
|
-
this.pendingId,
|
|
936
|
-
];
|
|
937
|
-
effect(() => {
|
|
938
|
-
if (!this.contentInitialized())
|
|
939
|
-
return;
|
|
940
|
-
const mode = this.ariaAssociationMode();
|
|
941
|
-
if (mode === 'none')
|
|
942
|
-
return;
|
|
943
|
-
const describedBy = this.ariaDescribedBy();
|
|
944
|
-
const activeIds = describedBy
|
|
945
|
-
? describedBy.split(/\s+/).filter(Boolean)
|
|
946
|
-
: [];
|
|
947
|
-
const shouldShowErrors = this.errorDisplay.shouldShowErrors();
|
|
948
|
-
const targets = (() => {
|
|
949
|
-
const controls = this.formControls();
|
|
950
|
-
if (mode === 'single-control') {
|
|
951
|
-
return controls.length === 1 ? controls : [];
|
|
952
|
-
}
|
|
953
|
-
return controls;
|
|
954
|
-
})();
|
|
955
|
-
for (const control of targets) {
|
|
956
|
-
const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), activeIds);
|
|
957
|
-
if (nextDescribedBy) {
|
|
958
|
-
control.setAttribute('aria-describedby', nextDescribedBy);
|
|
959
|
-
}
|
|
960
|
-
else {
|
|
961
|
-
control.removeAttribute('aria-describedby');
|
|
962
|
-
}
|
|
963
|
-
if (shouldShowErrors) {
|
|
964
|
-
control.setAttribute('aria-invalid', 'true');
|
|
965
|
-
}
|
|
966
|
-
else {
|
|
967
|
-
control.removeAttribute('aria-invalid');
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
});
|
|
971
|
-
effect(() => {
|
|
972
|
-
if (!this.contentInitialized())
|
|
973
|
-
return;
|
|
974
|
-
const mode = this.ariaAssociationMode();
|
|
975
|
-
if (mode === 'none') {
|
|
976
|
-
this.mutationObserver?.disconnect();
|
|
977
|
-
this.mutationObserver = null;
|
|
978
|
-
if (this.formControls().length > 0) {
|
|
979
|
-
this.formControls.set([]);
|
|
980
|
-
}
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
this.updateFormControls();
|
|
984
|
-
if (!this.mutationObserver) {
|
|
985
|
-
this.mutationObserver = new MutationObserver(() => {
|
|
986
|
-
this.updateFormControls();
|
|
987
|
-
});
|
|
988
|
-
this.mutationObserver.observe(this.elementRef.nativeElement, {
|
|
989
|
-
childList: true,
|
|
990
|
-
subtree: true,
|
|
991
|
-
});
|
|
270
|
+
// Handle Date objects
|
|
271
|
+
if (obj1 instanceof Date) {
|
|
272
|
+
return obj2 instanceof Date && obj1.getTime() === obj2.getTime();
|
|
273
|
+
}
|
|
274
|
+
// Handle RegExp objects
|
|
275
|
+
if (obj1 instanceof RegExp) {
|
|
276
|
+
return (obj2 instanceof RegExp &&
|
|
277
|
+
obj1.source === obj2.source &&
|
|
278
|
+
obj1.flags === obj2.flags);
|
|
279
|
+
}
|
|
280
|
+
// Handle Set objects (common in forms)
|
|
281
|
+
if (obj1 instanceof Set) {
|
|
282
|
+
if (!(obj2 instanceof Set) || obj1.size !== obj2.size) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
for (const value of obj1) {
|
|
286
|
+
if (!obj2.has(value)) {
|
|
287
|
+
return false;
|
|
992
288
|
}
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
this.mutationObserver?.disconnect();
|
|
996
|
-
this.mutationObserver = null;
|
|
997
|
-
});
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
998
291
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
292
|
+
// Handle Map objects (common in forms)
|
|
293
|
+
if (obj1 instanceof Map) {
|
|
294
|
+
if (!(obj2 instanceof Map) || obj1.size !== obj2.size) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
for (const [key, value] of obj1) {
|
|
298
|
+
if (!obj2.has(key) ||
|
|
299
|
+
!fastDeepEqual(value, obj2.get(key), maxDepth - 1)) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return true;
|
|
1001
304
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
305
|
+
// Handle plain objects
|
|
306
|
+
const keys1 = Object.keys(obj1);
|
|
307
|
+
const keys2 = Object.keys(obj2);
|
|
308
|
+
if (keys1.length !== keys2.length) {
|
|
309
|
+
return false;
|
|
1005
310
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
311
|
+
for (const key of keys1) {
|
|
312
|
+
if (!Object.hasOwn(obj2, key) ||
|
|
313
|
+
!fastDeepEqual(obj1[key], obj2[key], maxDepth - 1)) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
1009
316
|
}
|
|
1010
|
-
|
|
1011
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", 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"] }], ngImport: i0 }); }
|
|
317
|
+
return true;
|
|
1012
318
|
}
|
|
1013
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormErrorControlDirective, decorators: [{
|
|
1014
|
-
type: Directive,
|
|
1015
|
-
args: [{
|
|
1016
|
-
selector: '[formErrorControl], [ngxErrorControl]',
|
|
1017
|
-
exportAs: 'formErrorControl, ngxErrorControl',
|
|
1018
|
-
hostDirectives: [
|
|
1019
|
-
{
|
|
1020
|
-
directive: FormErrorDisplayDirective,
|
|
1021
|
-
inputs: ['errorDisplayMode'],
|
|
1022
|
-
},
|
|
1023
|
-
],
|
|
1024
|
-
}]
|
|
1025
|
-
}], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
|
|
1026
319
|
|
|
1027
320
|
const ROOT_FORM = 'rootForm';
|
|
1028
321
|
|
|
@@ -1268,7 +561,7 @@ function mergeValuesAndRawValues(form) {
|
|
|
1268
561
|
mergeRecursive(value, rawValue);
|
|
1269
562
|
return value;
|
|
1270
563
|
}
|
|
1271
|
-
function isPrimitive
|
|
564
|
+
function isPrimitive(value) {
|
|
1272
565
|
return (value === null || (typeof value !== 'object' && typeof value !== 'function'));
|
|
1273
566
|
}
|
|
1274
567
|
/**
|
|
@@ -1282,7 +575,7 @@ function isPrimitive$1(value) {
|
|
|
1282
575
|
*/
|
|
1283
576
|
function cloneDeep(object) {
|
|
1284
577
|
// Handle primitives (null, undefined, boolean, string, number, function)
|
|
1285
|
-
if (isPrimitive
|
|
578
|
+
if (isPrimitive(object)) {
|
|
1286
579
|
return object;
|
|
1287
580
|
}
|
|
1288
581
|
// Handle Date
|
|
@@ -1411,1069 +704,1980 @@ function getAllFormErrors(form) {
|
|
|
1411
704
|
return errors;
|
|
1412
705
|
}
|
|
1413
706
|
|
|
1414
|
-
const NGX_VEST_FORMS_ERRORS = {
|
|
1415
|
-
EXTRA_PROPERTY: {
|
|
1416
|
-
code: 'NGX-001',
|
|
1417
|
-
message: (path) => `Shape mismatch: Property '${path}' is present in the form value but not defined in the form shape.`,
|
|
1418
|
-
},
|
|
1419
|
-
TYPE_MISMATCH: {
|
|
1420
|
-
code: 'NGX-002',
|
|
1421
|
-
message: (path, expected, actual) => `Type mismatch at '${path}': Expected '${expected}' but got '${actual}'.`,
|
|
1422
|
-
},
|
|
1423
|
-
CONTROL_NOT_FOUND: {
|
|
1424
|
-
code: 'NGX-003',
|
|
1425
|
-
message: (path) => `Control not found: Could not find form control at path '${path}'. Check your [ngModel] name attributes.`,
|
|
1426
|
-
},
|
|
1427
|
-
};
|
|
1428
|
-
function logWarning(error, ...args) {
|
|
1429
|
-
console.warn(`[${error.code}] ${error.message(...args)}\nCheck your [formShape] input and the initial [formValue].`);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
707
|
/**
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
* This token allows you to configure the debounce time for validation config
|
|
1436
|
-
* dependencies at the application, route, or component level.
|
|
1437
|
-
*
|
|
1438
|
-
* @example
|
|
1439
|
-
* ```typescript
|
|
1440
|
-
* /// Global configuration
|
|
1441
|
-
* export const appConfig: ApplicationConfig = {
|
|
1442
|
-
* providers: [
|
|
1443
|
-
* {
|
|
1444
|
-
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
|
|
1445
|
-
* useValue: 200
|
|
1446
|
-
* }
|
|
1447
|
-
* ]
|
|
1448
|
-
* };
|
|
708
|
+
* Validates a form value against a shape to catch typos in `name` or `ngModelGroup` attributes.
|
|
1449
709
|
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
* component: CheckoutComponent,
|
|
1454
|
-
* providers: [
|
|
1455
|
-
* {
|
|
1456
|
-
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
|
|
1457
|
-
* useValue: 50
|
|
1458
|
-
* }
|
|
1459
|
-
* ]
|
|
1460
|
-
* }
|
|
710
|
+
* **What it checks:**
|
|
711
|
+
* - Extra properties: Keys in formValue that don't exist in shape (likely typos)
|
|
712
|
+
* - Type mismatches: When formValue has an object but shape expects a primitive
|
|
1461
713
|
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
* {
|
|
1466
|
-
* provide: NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN,
|
|
1467
|
-
* useValue: 0 // for testing
|
|
1468
|
-
* }
|
|
1469
|
-
* ]
|
|
1470
|
-
* })
|
|
1471
|
-
* export class TestFormComponent {}
|
|
1472
|
-
* ```
|
|
714
|
+
* **What it does NOT check:**
|
|
715
|
+
* - Missing properties: Keys in shape that don't exist in formValue
|
|
716
|
+
* (forms build incrementally with `NgxDeepPartial`, and `@if` conditionally renders fields)
|
|
1473
717
|
*
|
|
1474
|
-
*
|
|
1475
|
-
*/
|
|
1476
|
-
const NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN = new InjectionToken('NgxValidationConfigDebounceTime', {
|
|
1477
|
-
providedIn: 'root',
|
|
1478
|
-
factory: () => 100,
|
|
1479
|
-
});
|
|
1480
|
-
|
|
1481
|
-
/**
|
|
1482
|
-
* Type guard to check if a value is a primitive type.
|
|
718
|
+
* Only runs in development mode.
|
|
1483
719
|
*
|
|
1484
|
-
* @param
|
|
1485
|
-
* @
|
|
720
|
+
* @param formVal - The current form value
|
|
721
|
+
* @param shape - The expected shape (created with `NgxDeepRequired<T>`)
|
|
1486
722
|
*/
|
|
1487
|
-
function
|
|
1488
|
-
|
|
723
|
+
function validateShape(formVal, shape) {
|
|
724
|
+
if (isDevMode()) {
|
|
725
|
+
validateFormValueAgainstShape(formVal, shape);
|
|
726
|
+
}
|
|
1489
727
|
}
|
|
1490
728
|
/**
|
|
1491
|
-
*
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
* **Not intended for external use.** This function is used internally by the library
|
|
1495
|
-
* for performance-critical operations. Consider using your own comparison logic or
|
|
1496
|
-
* a library like lodash if you need shallow equality checks in your application.
|
|
1497
|
-
*
|
|
1498
|
-
* Optimized shallow equality check for objects.
|
|
1499
|
-
*
|
|
1500
|
-
* **Why this custom implementation is preferred:**
|
|
1501
|
-
* - **Performance**: Direct property comparison is significantly faster than JSON.stringify
|
|
1502
|
-
* - **Type Safety**: Handles null/undefined values correctly without serialization issues
|
|
1503
|
-
* - **Accuracy**: Doesn't suffer from JSON.stringify limitations (undefined values, functions, symbols)
|
|
1504
|
-
* - **Memory Efficient**: No temporary string creation or object serialization overhead
|
|
1505
|
-
*
|
|
1506
|
-
* **Use Cases:**
|
|
1507
|
-
* - Form value change detection where only top-level properties matter
|
|
1508
|
-
* - Quick object comparison in performance-critical code paths
|
|
1509
|
-
* - Validation triggers where deep comparison is unnecessary
|
|
1510
|
-
*
|
|
1511
|
-
* **Performance Comparison:**
|
|
1512
|
-
* ```typescript
|
|
1513
|
-
* /// ❌ Slow: JSON.stringify approach
|
|
1514
|
-
* JSON.stringify(obj1) === JSON.stringify(obj2)
|
|
1515
|
-
*
|
|
1516
|
-
* /// ✅ Fast: Direct property comparison
|
|
1517
|
-
* shallowEqual(obj1, obj2)
|
|
1518
|
-
* ```
|
|
1519
|
-
*
|
|
1520
|
-
* @param obj1 - First object to compare
|
|
1521
|
-
* @param obj2 - Second object to compare
|
|
1522
|
-
* @returns true if objects are shallowly equal (same keys and same values by reference)
|
|
729
|
+
* Recursively validates form value keys against the shape.
|
|
730
|
+
* Reports warnings for extra properties and type mismatches.
|
|
1523
731
|
*/
|
|
1524
|
-
function
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
732
|
+
function validateFormValueAgainstShape(formValue, shape, path = '') {
|
|
733
|
+
for (const key of Object.keys(formValue)) {
|
|
734
|
+
const value = formValue[key];
|
|
735
|
+
const fieldPath = path ? `${path}.${key}` : key;
|
|
736
|
+
// Skip null/undefined values (valid during form initialization)
|
|
737
|
+
if (value == null) {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
// For array items (numeric keys > 0), compare against the first item in shape
|
|
741
|
+
// since we only define one example item in the shape for arrays
|
|
742
|
+
const isNumericKey = !isNaN(parseFloat(key));
|
|
743
|
+
const shapeKey = isNumericKey && parseFloat(key) > 0 ? '0' : key;
|
|
744
|
+
const shapeValue = shape?.[shapeKey];
|
|
745
|
+
// Skip Date fields receiving empty strings (common in date picker libraries)
|
|
746
|
+
if (shapeValue instanceof Date && value === '') {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
// Handle object values (recurse into nested objects)
|
|
750
|
+
if (typeof value === 'object') {
|
|
751
|
+
// Type mismatch: formValue has object, but shape expects primitive
|
|
752
|
+
if (!isNumericKey &&
|
|
753
|
+
(typeof shapeValue !== 'object' || shapeValue === null)) {
|
|
754
|
+
logWarning(NGX_VEST_FORMS_ERRORS.TYPE_MISMATCH, fieldPath, 'primitive', 'object');
|
|
755
|
+
}
|
|
756
|
+
// Recurse into nested object
|
|
757
|
+
validateFormValueAgainstShape(value, shapeValue ?? {}, fieldPath);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
// Extra property: key exists in formValue but not in shape (likely a typo)
|
|
761
|
+
if (!isNumericKey && shape && !(shapeKey in shape)) {
|
|
762
|
+
logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
|
|
1544
763
|
}
|
|
1545
764
|
}
|
|
1546
|
-
return true;
|
|
1547
765
|
}
|
|
766
|
+
|
|
1548
767
|
/**
|
|
1549
|
-
*
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
*
|
|
1556
|
-
* Fast deep equality check optimized for form values and Angular applications.
|
|
1557
|
-
*
|
|
1558
|
-
* **Why this custom implementation is preferred over alternatives:**
|
|
1559
|
-
*
|
|
1560
|
-
* **vs JSON.stringify():**
|
|
1561
|
-
* - **10-100x faster**: Direct comparison without string serialization overhead
|
|
1562
|
-
* - **Accurate**: Handles Date objects, RegExp, undefined values, and functions correctly
|
|
1563
|
-
* - **Memory efficient**: No temporary string creation or garbage collection pressure
|
|
1564
|
-
* - **Preserves semantics**: Maintains type information during comparison
|
|
1565
|
-
*
|
|
1566
|
-
* **vs structuredClone():**
|
|
1567
|
-
* - **Wrong purpose**: structuredClone creates copies, not comparisons
|
|
1568
|
-
* - **Performance**: Would require cloning both objects just to compare them
|
|
1569
|
-
* - **Memory waste**: Creates unnecessary deep copies, doubling memory usage
|
|
1570
|
-
* - **Still incomplete**: Even after cloning, you'd still need a comparison function
|
|
1571
|
-
*
|
|
1572
|
-
* **vs External libraries (lodash.isEqual, etc.):**
|
|
1573
|
-
* - **Bundle size**: Zero dependencies, smaller application bundles
|
|
1574
|
-
* - **Form-specific**: Optimized for common Angular form data patterns
|
|
1575
|
-
* - **Type safety**: Full TypeScript integration with strict typing
|
|
1576
|
-
* - **Performance**: Tailored algorithms for form value comparison use cases
|
|
768
|
+
* Duration (in milliseconds) to keep fields marked as "in-progress" after validation.
|
|
769
|
+
* This prevents immediate re-triggering of bidirectional validations.
|
|
770
|
+
* Increased from 100ms to 500ms to give validators enough time to complete and propagate.
|
|
771
|
+
*/
|
|
772
|
+
const VALIDATION_IN_PROGRESS_TIMEOUT_MS = 500;
|
|
773
|
+
/**
|
|
774
|
+
* Main form directive for ngx-vest-forms that bridges Angular template-driven forms with Vest.js validation.
|
|
1577
775
|
*
|
|
1578
|
-
*
|
|
1579
|
-
* -
|
|
1580
|
-
* -
|
|
1581
|
-
* -
|
|
1582
|
-
* -
|
|
1583
|
-
* - RegExp objects (by source and flags comparison)
|
|
1584
|
-
* - Set objects (by size and value membership)
|
|
1585
|
-
* - Map objects (by size and key-value pairs)
|
|
776
|
+
* This directive provides:
|
|
777
|
+
* - **Unidirectional data flow**: Use `[ngModel]` (not `[(ngModel)]`) with `(formValueChange)` for predictable state updates
|
|
778
|
+
* - **Vest.js integration**: Automatic async validators from Vest suites with field-level optimization
|
|
779
|
+
* - **Validation dependencies**: Configure cross-field validation triggers via `validationConfig`
|
|
780
|
+
* - **Form state**: Access validity, errors, and values through the `formState` signal
|
|
1586
781
|
*
|
|
1587
|
-
*
|
|
1588
|
-
* - **Circular reference protection**: MaxDepth parameter prevents infinite recursion
|
|
1589
|
-
* - **Type coercion prevention**: Strict type checking before comparison
|
|
1590
|
-
* - **Null safety**: Proper handling of null and undefined values
|
|
782
|
+
* @usageNotes
|
|
1591
783
|
*
|
|
1592
|
-
*
|
|
1593
|
-
* ```
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
* ///
|
|
1598
|
-
* /// Memory usage:
|
|
1599
|
-
* /// JSON.stringify: Creates temporary strings (high GC pressure)
|
|
1600
|
-
* /// fastDeepEqual: Zero allocations during comparison
|
|
784
|
+
* ### Basic Usage
|
|
785
|
+
* ```html
|
|
786
|
+
* <form ngxVestForm [suite]="validationSuite" (formValueChange)="formValue.set($event)">
|
|
787
|
+
* <input name="email" [ngModel]="formValue().email" />
|
|
788
|
+
* </form>
|
|
1601
789
|
* ```
|
|
1602
790
|
*
|
|
1603
|
-
*
|
|
791
|
+
* ### With Validation Dependencies
|
|
792
|
+
* ```html
|
|
793
|
+
* <form ngxVestForm [suite]="suite" [validationConfig]="validationConfig">
|
|
794
|
+
* <input name="password" [ngModel]="formValue().password" />
|
|
795
|
+
* <input name="confirmPassword" [ngModel]="formValue().confirmPassword" />
|
|
796
|
+
* </form>
|
|
797
|
+
* ```
|
|
1604
798
|
* ```typescript
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
799
|
+
* validationConfig = { 'password': ['confirmPassword'] };
|
|
800
|
+
* ```
|
|
1607
801
|
*
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
802
|
+
* ### Accessing Form State
|
|
803
|
+
* ```typescript
|
|
804
|
+
* vestForm = viewChild.required('vestForm', { read: FormDirective });
|
|
805
|
+
* isValid = computed(() => this.vestForm().formState().valid);
|
|
1612
806
|
* ```
|
|
1613
807
|
*
|
|
1614
|
-
* @
|
|
1615
|
-
* @
|
|
1616
|
-
* @param maxDepth - Maximum recursion depth to prevent infinite loops (default: 10)
|
|
1617
|
-
* @returns true if objects are deeply equal by value
|
|
808
|
+
* @see {@link https://github.com/ngx-vest-forms/ngx-vest-forms} for full documentation
|
|
809
|
+
* @publicApi
|
|
1618
810
|
*/
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
811
|
+
class FormDirective {
|
|
812
|
+
// Track last linked value to prevent unnecessary updates
|
|
813
|
+
#lastLinkedValue;
|
|
814
|
+
#lastSyncedFormValue;
|
|
815
|
+
#lastSyncedModelValue;
|
|
816
|
+
// Internal signal tracking form value changes via statusChanges
|
|
817
|
+
#value;
|
|
818
|
+
/**
|
|
819
|
+
* LinkedSignal that computes form values from Angular form state.
|
|
820
|
+
* This eliminates timing issues with the previous dual-effect pattern.
|
|
821
|
+
*/
|
|
822
|
+
#formValueSignal;
|
|
823
|
+
/**
|
|
824
|
+
* Track the Angular form status as a signal for advanced status flags
|
|
825
|
+
*/
|
|
826
|
+
#statusSignal;
|
|
827
|
+
constructor() {
|
|
828
|
+
this.ngForm = inject(NgForm, { self: true });
|
|
829
|
+
this.destroyRef = inject(DestroyRef);
|
|
830
|
+
this.cdr = inject(ChangeDetectorRef);
|
|
831
|
+
this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
|
|
832
|
+
/**
|
|
833
|
+
* Public signal storing field warnings keyed by field path.
|
|
834
|
+
* This allows warnings to be stored and displayed without affecting field validity.
|
|
835
|
+
* Angular's control.errors !== null marks a field as invalid, so we store warnings
|
|
836
|
+
* separately when they exist without errors.
|
|
837
|
+
*/
|
|
838
|
+
this.fieldWarnings = signal(new Map(), ...(ngDevMode ? [{ debugName: "fieldWarnings" }] : []));
|
|
839
|
+
// Track last linked value to prevent unnecessary updates
|
|
840
|
+
this.#lastLinkedValue = null;
|
|
841
|
+
this.#lastSyncedFormValue = null;
|
|
842
|
+
this.#lastSyncedModelValue = null;
|
|
843
|
+
// Internal signal tracking form value changes via statusChanges
|
|
844
|
+
this.#value = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
|
|
845
|
+
/**
|
|
846
|
+
* LinkedSignal that computes form values from Angular form state.
|
|
847
|
+
* This eliminates timing issues with the previous dual-effect pattern.
|
|
848
|
+
*/
|
|
849
|
+
this.#formValueSignal = linkedSignal(() => {
|
|
850
|
+
// Track form value changes
|
|
851
|
+
this.#value();
|
|
852
|
+
const raw = mergeValuesAndRawValues(this.ngForm.form);
|
|
853
|
+
if (Object.keys(this.ngForm.form.controls).length > 0) {
|
|
854
|
+
this.#lastLinkedValue = raw;
|
|
855
|
+
return raw;
|
|
856
|
+
}
|
|
857
|
+
else if (this.#lastLinkedValue !== null) {
|
|
858
|
+
return this.#lastLinkedValue;
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
}, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : []));
|
|
862
|
+
/**
|
|
863
|
+
* Track the Angular form status as a signal for advanced status flags
|
|
864
|
+
*/
|
|
865
|
+
this.#statusSignal = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
|
|
866
|
+
/**
|
|
867
|
+
* Computed signal for form state with validity and errors.
|
|
868
|
+
* Used by templates and tests as vestForm.formState().valid/errors
|
|
869
|
+
*
|
|
870
|
+
* Uses custom equality function to prevent unnecessary recalculations
|
|
871
|
+
* when form status changes but actual values/errors remain the same.
|
|
872
|
+
*/
|
|
873
|
+
this.formState = computed(() => {
|
|
874
|
+
// Tie to status signal to ensure recomputation on validation changes
|
|
875
|
+
this.#statusSignal();
|
|
876
|
+
return {
|
|
877
|
+
valid: this.ngForm.form.valid,
|
|
878
|
+
errors: getAllFormErrors(this.ngForm.form),
|
|
879
|
+
value: this.#formValueSignal(),
|
|
880
|
+
};
|
|
881
|
+
}, { ...(ngDevMode ? { debugName: "formState" } : {}), equal: (a, b) => {
|
|
882
|
+
// Fast path: reference equality
|
|
883
|
+
if (a === b)
|
|
884
|
+
return true;
|
|
885
|
+
// Null/undefined check
|
|
886
|
+
if (!a || !b)
|
|
887
|
+
return false;
|
|
888
|
+
// Deep equality check for form state properties
|
|
889
|
+
return (a.valid === b.valid &&
|
|
890
|
+
fastDeepEqual(a.errors, b.errors) &&
|
|
891
|
+
fastDeepEqual(a.value, b.value));
|
|
892
|
+
} });
|
|
893
|
+
/**
|
|
894
|
+
* The value of the form, this is needed for the validation part.
|
|
895
|
+
* Using input() here because two-way binding is provided via formValueChange output.
|
|
896
|
+
* In the minimal core directive (form-core.directive.ts), this would be model() instead.
|
|
897
|
+
*/
|
|
898
|
+
this.formValue = input(null, ...(ngDevMode ? [{ debugName: "formValue" }] : []));
|
|
899
|
+
/**
|
|
900
|
+
* Static vest suite that will be used to feed our angular validators.
|
|
901
|
+
* Accepts both NgxVestSuite and NgxTypedVestSuite through compatible type signatures.
|
|
902
|
+
* NgxTypedVestSuite<T> is assignable to NgxVestSuite<T> due to bivariance and
|
|
903
|
+
* FormFieldName<T> (string literal union) being assignable to string.
|
|
904
|
+
*/
|
|
905
|
+
this.suite = input(null, ...(ngDevMode ? [{ debugName: "suite" }] : []));
|
|
906
|
+
/**
|
|
907
|
+
* The shape of our form model. This is a deep required version of the form model
|
|
908
|
+
* The goal is to add default values to the shape so when the template-driven form
|
|
909
|
+
* contains values that shouldn't be there (typo's) that the developer gets run-time
|
|
910
|
+
* errors in dev mode
|
|
911
|
+
*/
|
|
912
|
+
this.formShape = input(null, ...(ngDevMode ? [{ debugName: "formShape" }] : []));
|
|
913
|
+
/**
|
|
914
|
+
* Updates the validation config which is a dynamic object that will be used to
|
|
915
|
+
* trigger validations on the dependant fields
|
|
916
|
+
* Eg: ```typescript
|
|
917
|
+
* validationConfig = {
|
|
918
|
+
* 'passwords.password': ['passwords.confirmPassword']
|
|
919
|
+
* }
|
|
920
|
+
* ```
|
|
921
|
+
*
|
|
922
|
+
* This will trigger the updateValueAndValidity on passwords.confirmPassword every time the passwords.password gets a new value
|
|
923
|
+
*
|
|
924
|
+
* @param v
|
|
925
|
+
*/
|
|
926
|
+
this.validationConfig = input(null, ...(ngDevMode ? [{ debugName: "validationConfig" }] : []));
|
|
927
|
+
this.pending$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v === 'PENDING'), distinctUntilChanged());
|
|
928
|
+
/**
|
|
929
|
+
* Emits every time the form status changes in a state
|
|
930
|
+
* that is not PENDING
|
|
931
|
+
* We need this to assure that the form is in 'idle' state
|
|
932
|
+
*/
|
|
933
|
+
this.idle$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING'), distinctUntilChanged());
|
|
934
|
+
/**
|
|
935
|
+
* Triggered as soon as the form value changes
|
|
936
|
+
* It also contains the disabled values (raw values)
|
|
937
|
+
*
|
|
938
|
+
* Cleanup is handled automatically by the directive when it's destroyed.
|
|
939
|
+
*/
|
|
940
|
+
this.formValueChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof ValueChangeEvent), map((v) => v.value), distinctUntilChanged((prev, curr) => {
|
|
941
|
+
// Use efficient deep equality instead of JSON.stringify for better performance
|
|
942
|
+
return fastDeepEqual(prev, curr);
|
|
943
|
+
}), map(() => mergeValuesAndRawValues(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
|
|
944
|
+
/**
|
|
945
|
+
* Emits an object with all the errors of the form
|
|
946
|
+
* every time a form control or form groups changes its status to valid or invalid
|
|
947
|
+
*
|
|
948
|
+
* For submit events, waits for async validation (including ROOT_FORM) to complete
|
|
949
|
+
* before emitting errors. This ensures ROOT_FORM errors are included in the output.
|
|
950
|
+
*
|
|
951
|
+
* Cleanup is handled automatically by the directive when it's destroyed.
|
|
952
|
+
*/
|
|
953
|
+
this.errorsChange = outputFromObservable(merge(
|
|
954
|
+
// Status change events (non-PENDING) - emit immediately
|
|
955
|
+
this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING')),
|
|
956
|
+
// Submit events - wait for async validation to complete before emitting
|
|
957
|
+
this.ngForm.ngSubmit.pipe(switchMap(() => {
|
|
958
|
+
// If form is PENDING (async validation in progress), wait for it to complete
|
|
959
|
+
if (this.ngForm.form.status === 'PENDING') {
|
|
960
|
+
return this.ngForm.form.statusChanges.pipe(filter((status) => status !== 'PENDING'), take(1));
|
|
961
|
+
}
|
|
962
|
+
// Form not pending, emit immediately
|
|
963
|
+
return of(this.ngForm.form.status);
|
|
964
|
+
}))).pipe(map(() => getAllFormErrors(this.ngForm.form)), takeUntilDestroyed(this.destroyRef)));
|
|
965
|
+
/**
|
|
966
|
+
* Triggered as soon as the form becomes dirty
|
|
967
|
+
*
|
|
968
|
+
* Cleanup is handled automatically by the directive when it's destroyed.
|
|
969
|
+
*/
|
|
970
|
+
this.dirtyChange = outputFromObservable(this.ngForm.form.events.pipe(filter((v) => v instanceof PristineChangeEvent), map((v) => !v.pristine), startWith(this.ngForm.form.dirty), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
|
|
971
|
+
/**
|
|
972
|
+
* Fired when the status of the root form changes.
|
|
973
|
+
*/
|
|
974
|
+
this.statusChanges$ = this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status), distinctUntilChanged());
|
|
975
|
+
/**
|
|
976
|
+
* Triggered When the form becomes valid but waits until the form is idle
|
|
977
|
+
*
|
|
978
|
+
* Cleanup is handled automatically by the directive when it's destroyed.
|
|
979
|
+
*/
|
|
980
|
+
this.validChange = outputFromObservable(this.statusChanges$.pipe(filter((e) => e === 'VALID' || e === 'INVALID'), map((v) => v === 'VALID'), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)));
|
|
981
|
+
/**
|
|
982
|
+
* Track validation in progress to prevent circular triggering (Issue #19)
|
|
983
|
+
*/
|
|
984
|
+
this.validationInProgress = new Set();
|
|
985
|
+
this.destroyRef.onDestroy(() => {
|
|
986
|
+
this.fieldWarnings.set(new Map());
|
|
987
|
+
});
|
|
988
|
+
/**
|
|
989
|
+
* Trigger shape validations if the form gets updated
|
|
990
|
+
* This is how we can throw run-time errors
|
|
991
|
+
*/
|
|
992
|
+
if (isDevMode()) {
|
|
993
|
+
effect(() => {
|
|
994
|
+
const v = this.formValue();
|
|
995
|
+
if (v && this.formShape()) {
|
|
996
|
+
validateShape(v, this.formShape());
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Mark all the fields as touched when the form is submitted
|
|
1002
|
+
*/
|
|
1003
|
+
this.ngForm.ngSubmit
|
|
1004
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
1005
|
+
.subscribe(() => {
|
|
1006
|
+
this.ngForm.form.markAllAsTouched();
|
|
1007
|
+
});
|
|
1008
|
+
/**
|
|
1009
|
+
* Single bidirectional synchronization effect using linkedSignal.
|
|
1010
|
+
* Uses proper deep comparison and change tracking for correct sync direction.
|
|
1011
|
+
* Note: formValue is read-only input(), so we emit changes via formValueChange output.
|
|
1012
|
+
*/
|
|
1013
|
+
effect(() => {
|
|
1014
|
+
const formValue = this.#formValueSignal();
|
|
1015
|
+
const modelValue = this.formValue();
|
|
1016
|
+
// Skip if either is null
|
|
1017
|
+
if (!formValue && !modelValue)
|
|
1018
|
+
return;
|
|
1019
|
+
// Compute change flags first
|
|
1020
|
+
const formChanged = !fastDeepEqual(formValue, this.#lastSyncedFormValue);
|
|
1021
|
+
const modelChanged = !fastDeepEqual(modelValue, this.#lastSyncedModelValue);
|
|
1022
|
+
// Early return if nothing changed
|
|
1023
|
+
if (!formChanged && !modelChanged) {
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (formChanged && !modelChanged) {
|
|
1027
|
+
// Form was modified by user -> form wins
|
|
1028
|
+
// Note: We can't call this.formValue.set() since it's an input()
|
|
1029
|
+
// The formValueChange output will emit the new value
|
|
1030
|
+
// Use untracked() to avoid infinite loops - we're updating tracking state here
|
|
1031
|
+
untracked(() => {
|
|
1032
|
+
this.#lastSyncedFormValue = formValue;
|
|
1033
|
+
this.#lastSyncedModelValue = formValue;
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
else if (modelChanged && !formChanged) {
|
|
1037
|
+
// Model was modified programmatically -> model wins
|
|
1038
|
+
// Use untracked() to avoid infinite loops - we're updating tracking state here
|
|
1039
|
+
untracked(() => {
|
|
1040
|
+
// Update form controls with new model values
|
|
1041
|
+
if (modelValue) {
|
|
1042
|
+
// IMPORTANT: Use root patchValue instead of per-key setValue.
|
|
1043
|
+
// - Supports nested objects (ngModelGroup) without throwing when partial objects are provided.
|
|
1044
|
+
// - patchValue ignores missing controls/keys, which is compatible with DeepPartial form models.
|
|
1045
|
+
// - emitEvent:false prevents feedback loops; validation still updates internally.
|
|
1046
|
+
this.ngForm.form.patchValue(modelValue, { emitEvent: false });
|
|
1047
|
+
}
|
|
1048
|
+
this.#lastSyncedFormValue = modelValue;
|
|
1049
|
+
this.#lastSyncedModelValue = modelValue;
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
else if (formChanged && modelChanged) {
|
|
1053
|
+
// Both form and model changed simultaneously
|
|
1054
|
+
// Check if they changed to the same value (synchronized change) or different values (conflict)
|
|
1055
|
+
const valuesEqual = fastDeepEqual(formValue, modelValue);
|
|
1056
|
+
if (valuesEqual) {
|
|
1057
|
+
// Both changed to the same value - this is a synchronized change, not a conflict
|
|
1058
|
+
// Just update tracking to acknowledge the change
|
|
1059
|
+
untracked(() => {
|
|
1060
|
+
this.#lastSyncedFormValue = formValue;
|
|
1061
|
+
this.#lastSyncedModelValue = formValue;
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
// Both changed to different values - this is a true conflict
|
|
1066
|
+
// This is an edge case that should rarely happen in practice.
|
|
1067
|
+
// We intentionally do nothing here to avoid breaking the Angular event flow.
|
|
1068
|
+
// The form will continue with its current values, and validation will run normally.
|
|
1069
|
+
// The next change (either form or model) will trigger proper synchronization.
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
// Set up validation config reactively
|
|
1074
|
+
this.#setupValidationConfig();
|
|
1623
1075
|
}
|
|
1624
|
-
|
|
1625
|
-
|
|
1076
|
+
/**
|
|
1077
|
+
* Manually trigger form validation update.
|
|
1078
|
+
*
|
|
1079
|
+
* This is useful when form structure changes but no control values change,
|
|
1080
|
+
* which means validation state might be stale. This method forces a re-evaluation
|
|
1081
|
+
* of all form validators and updates the form validity state.
|
|
1082
|
+
*
|
|
1083
|
+
* **IMPORTANT: This method validates ALL form fields by design.**
|
|
1084
|
+
* This is intentional for structure changes as conditional validators may now
|
|
1085
|
+
* apply to different fields, requiring a complete validation refresh.
|
|
1086
|
+
*
|
|
1087
|
+
* **CRITICAL: This method does NOT mark fields as touched or show errors.**
|
|
1088
|
+
* It only re-runs validation logic. To show all errors (e.g., on submit),
|
|
1089
|
+
* use `markAllAsTouched()` instead or in combination.
|
|
1090
|
+
*
|
|
1091
|
+
* **When to use each:**
|
|
1092
|
+
* - `triggerFormValidation()` - Re-run validation when structure changes
|
|
1093
|
+
* - `markAllAsTouched()` - Show all errors to user (e.g., on submit)
|
|
1094
|
+
* - Both together - Rare, only if structure changed AND you want to show errors
|
|
1095
|
+
*
|
|
1096
|
+
* **Note on form submission:**
|
|
1097
|
+
* When using the default error display mode (`on-blur-or-submit`), you typically
|
|
1098
|
+
* don't need to call this method on submit. The form directive automatically marks
|
|
1099
|
+
* all fields as touched on `ngSubmit`, and errors will display automatically.
|
|
1100
|
+
* Only use this method when form structure changes without value changes.
|
|
1101
|
+
*
|
|
1102
|
+
* **Use Cases:**
|
|
1103
|
+
* - Conditionally showing/hiding form controls based on other field values
|
|
1104
|
+
* - Adding or removing form controls dynamically
|
|
1105
|
+
* - Switching between different form layouts where validation requirements change
|
|
1106
|
+
* - Any scenario where form structure changes but no ValueChangeEvent is triggered
|
|
1107
|
+
*
|
|
1108
|
+
* **Example:**
|
|
1109
|
+
* When switching from a form with required input fields to one with only informational content,
|
|
1110
|
+
* the form should become valid, but this won't happen automatically
|
|
1111
|
+
* when no value changes occur (e.g., switching from input fields to informational content).
|
|
1112
|
+
*
|
|
1113
|
+
* **Performance Note:**
|
|
1114
|
+
* This method calls `updateValueAndValidity({ emitEvent: true })` on the root form,
|
|
1115
|
+
* which validates all form controls. For large forms, consider if more granular
|
|
1116
|
+
* validation updates are possible.
|
|
1117
|
+
*
|
|
1118
|
+
* @example
|
|
1119
|
+
* ```typescript
|
|
1120
|
+
* /// After changing form structure
|
|
1121
|
+
* onProcedureTypeChange(newType: string) {
|
|
1122
|
+
* this.procedureType.set(newType);
|
|
1123
|
+
* /// Structure changed but no control values changed
|
|
1124
|
+
* this.formDirective.triggerFormValidation();
|
|
1125
|
+
* }
|
|
1126
|
+
*
|
|
1127
|
+
* /// For submit with multiple forms (show all errors)
|
|
1128
|
+
* submitAll() {
|
|
1129
|
+
* // Mark all as touched to show errors
|
|
1130
|
+
* this.form1Ref().markAllAsTouched();
|
|
1131
|
+
* this.form2Ref().markAllAsTouched();
|
|
1132
|
+
* // Only needed if structure changed without value changes
|
|
1133
|
+
* // this.form1Ref().triggerFormValidation();
|
|
1134
|
+
* // this.form2Ref().triggerFormValidation();
|
|
1135
|
+
* }
|
|
1136
|
+
* ```
|
|
1137
|
+
*/
|
|
1138
|
+
triggerFormValidation(path) {
|
|
1139
|
+
if (path) {
|
|
1140
|
+
const control = this.ngForm.form.get(path);
|
|
1141
|
+
if (control) {
|
|
1142
|
+
control.updateValueAndValidity({ emitEvent: true });
|
|
1143
|
+
}
|
|
1144
|
+
else if (isDevMode()) {
|
|
1145
|
+
logWarning(NGX_VEST_FORMS_ERRORS.CONTROL_NOT_FOUND, path);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
// Update all form controls validity which will trigger all form events
|
|
1150
|
+
this.ngForm.form.updateValueAndValidity({ emitEvent: true });
|
|
1151
|
+
}
|
|
1626
1152
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1153
|
+
/**
|
|
1154
|
+
* Convenience method to mark all form controls as touched.
|
|
1155
|
+
*
|
|
1156
|
+
* This is useful for showing all validation errors at once, typically when
|
|
1157
|
+
* the user clicks a submit button. When a field is marked as touched,
|
|
1158
|
+
* the error display logic (based on `errorDisplayMode`) will show its errors.
|
|
1159
|
+
*
|
|
1160
|
+
* **Note on automatic behavior:**
|
|
1161
|
+
* When using the default error display mode (`on-blur-or-submit`), you typically
|
|
1162
|
+
* don't need to call this method manually for regular form submissions. The form
|
|
1163
|
+
* directive automatically marks all fields as touched on `ngSubmit`, so errors
|
|
1164
|
+
* will display automatically when the user submits the form.
|
|
1165
|
+
*
|
|
1166
|
+
* **When to use this method:**
|
|
1167
|
+
* - Multiple forms with a single submit button (forms without their own submit)
|
|
1168
|
+
* - Programmatic form submission without triggering `ngSubmit`
|
|
1169
|
+
* - Custom validation flows outside the normal submit process
|
|
1170
|
+
*
|
|
1171
|
+
* **Note:** This method only marks fields as touched—it does NOT re-run validation.
|
|
1172
|
+
* If you also need to re-run validation (e.g., after structure changes), call
|
|
1173
|
+
* `triggerFormValidation()` as well.
|
|
1174
|
+
*
|
|
1175
|
+
* @example
|
|
1176
|
+
* ```typescript
|
|
1177
|
+
* /// Standard form submission - NO need to call markAllAsTouched()
|
|
1178
|
+
* /// The directive handles this automatically on ngSubmit
|
|
1179
|
+
* <form ngxVestForm (ngSubmit)="save()">
|
|
1180
|
+
* <button type="submit">Submit</button>
|
|
1181
|
+
* </form>
|
|
1182
|
+
*
|
|
1183
|
+
* /// Multiple forms with one submit button
|
|
1184
|
+
* submitAll() {
|
|
1185
|
+
* this.form1().markAllAsTouched();
|
|
1186
|
+
* this.form2().markAllAsTouched();
|
|
1187
|
+
* if (this.form1().formState().valid && this.form2().formState().valid) {
|
|
1188
|
+
* /// Submit all forms
|
|
1189
|
+
* }
|
|
1190
|
+
* }
|
|
1191
|
+
* ```
|
|
1192
|
+
*/
|
|
1193
|
+
markAllAsTouched() {
|
|
1194
|
+
this.ngForm.form.markAllAsTouched();
|
|
1629
1195
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1196
|
+
/**
|
|
1197
|
+
* Resets the form to a pristine, untouched state with optional new values.
|
|
1198
|
+
*
|
|
1199
|
+
* This method properly resets the form by:
|
|
1200
|
+
* 1. Resetting Angular's underlying NgForm with the provided value
|
|
1201
|
+
* 2. Clearing the bidirectional sync tracking state
|
|
1202
|
+
* 3. Forcing a form validity update to clear any stale validation errors
|
|
1203
|
+
*
|
|
1204
|
+
* **Why this method exists:**
|
|
1205
|
+
* When using the pattern `formValue.set({})` to reset a form, there can be a timing
|
|
1206
|
+
* issue where the form controls in the DOM still hold their old values while the
|
|
1207
|
+
* signal has already been updated. This creates a conflict in the bidirectional
|
|
1208
|
+
* sync logic, requiring workarounds like calling `formValue.set({})` twice with
|
|
1209
|
+
* a setTimeout. This method provides a proper solution by:
|
|
1210
|
+
* - Calling Angular's `NgForm.resetForm()` which properly clears all controls
|
|
1211
|
+
* - Clearing the internal sync tracking state to avoid stale comparisons
|
|
1212
|
+
* - Triggering a form validity update to ensure validation state is current
|
|
1213
|
+
*
|
|
1214
|
+
* **Usage:**
|
|
1215
|
+
* Instead of the double-set workaround:
|
|
1216
|
+
* ```typescript
|
|
1217
|
+
* // ❌ Old workaround (avoid)
|
|
1218
|
+
* reset(): void {
|
|
1219
|
+
* this.formValue.set({});
|
|
1220
|
+
* setTimeout(() => this.formValue.set({}), 0);
|
|
1221
|
+
* }
|
|
1222
|
+
*
|
|
1223
|
+
* // ✅ Preferred approach
|
|
1224
|
+
* vestForm = viewChild.required('vestForm', { read: FormDirective });
|
|
1225
|
+
* reset(): void {
|
|
1226
|
+
* this.formValue.set({});
|
|
1227
|
+
* this.vestForm().resetForm();
|
|
1228
|
+
* }
|
|
1229
|
+
* ```
|
|
1230
|
+
*
|
|
1231
|
+
* **With new values:**
|
|
1232
|
+
* ```typescript
|
|
1233
|
+
* // Reset and set new initial values
|
|
1234
|
+
* resetWithDefaults(): void {
|
|
1235
|
+
* const defaults = { firstName: '', lastName: '', age: 18 };
|
|
1236
|
+
* this.formValue.set(defaults);
|
|
1237
|
+
* this.vestForm().resetForm(defaults);
|
|
1238
|
+
* }
|
|
1239
|
+
* ```
|
|
1240
|
+
*
|
|
1241
|
+
* @param value - Optional new value to reset the form to. If not provided,
|
|
1242
|
+
* resets to empty/default values.
|
|
1243
|
+
*
|
|
1244
|
+
* @see {@link markAllAsTouched} for showing validation errors
|
|
1245
|
+
* @see {@link triggerFormValidation} for re-running validation without reset
|
|
1246
|
+
*/
|
|
1247
|
+
resetForm(value) {
|
|
1248
|
+
// Reset Angular's form to clear all controls and mark as pristine/untouched
|
|
1249
|
+
this.ngForm.resetForm(value ?? undefined);
|
|
1250
|
+
// Clear any stored warnings to avoid stale messages after reset
|
|
1251
|
+
this.fieldWarnings.set(new Map());
|
|
1252
|
+
// Clear the bidirectional sync tracking state so the next formValue change
|
|
1253
|
+
// is treated as a model change (not a conflict with stale form values)
|
|
1254
|
+
this.#lastSyncedFormValue = null;
|
|
1255
|
+
this.#lastSyncedModelValue = null;
|
|
1256
|
+
this.#lastLinkedValue = null;
|
|
1257
|
+
// Force change detection to ensure DOM updates are reflected
|
|
1258
|
+
// Note: This is still needed even with signals because we're modifying NgForm
|
|
1259
|
+
// (reactive forms), not signals. The formValue signal updates happen in the
|
|
1260
|
+
// consumer component. detectChanges() ensures NgForm's reset is reflected in
|
|
1261
|
+
// the DOM before we update validity.
|
|
1262
|
+
this.cdr.detectChanges();
|
|
1263
|
+
// Trigger validation update to clear any stale errors
|
|
1264
|
+
// Now synchronous since detectChanges() has flushed DOM updates
|
|
1265
|
+
this.ngForm.form.updateValueAndValidity({ emitEvent: true });
|
|
1632
1266
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1267
|
+
/**
|
|
1268
|
+
* This will feed the formValueCache, debounce it till the next tick
|
|
1269
|
+
* and create an asynchronous validator that runs a vest suite
|
|
1270
|
+
* @param field
|
|
1271
|
+
* @param validationOptions
|
|
1272
|
+
* @returns an asynchronous validator function
|
|
1273
|
+
*/
|
|
1274
|
+
/**
|
|
1275
|
+
* V2 async validator pattern: uses timer() + switchMap for proper re-validation.
|
|
1276
|
+
* Each invocation builds a fresh one-shot observable, ensuring progressive validation.
|
|
1277
|
+
*/
|
|
1278
|
+
createAsyncValidator(field, validationOptions) {
|
|
1279
|
+
const suite = this.suite();
|
|
1280
|
+
if (!suite)
|
|
1281
|
+
return () => of(null);
|
|
1282
|
+
return (control) => {
|
|
1283
|
+
const model = mergeValuesAndRawValues(this.ngForm.form);
|
|
1284
|
+
// Targeted snapshot with candidate value injected at path
|
|
1285
|
+
// mergeValuesAndRawValues already returns a deep clone (via structuredClone),
|
|
1286
|
+
// so we can modify it directly without affecting the form state.
|
|
1287
|
+
const snapshot = model;
|
|
1288
|
+
setValueAtPath(snapshot, field, control.value);
|
|
1289
|
+
// Use timer() instead of ReplaySubject for proper debouncing
|
|
1290
|
+
return timer(validationOptions.debounceTime ?? 0).pipe(map(() => snapshot), switchMap((snap) => new Observable((observer) => {
|
|
1291
|
+
try {
|
|
1292
|
+
// Cast to NgxVestSuite to accept string field parameter
|
|
1293
|
+
// Both NgxVestSuite and NgxTypedVestSuite work with string at runtime
|
|
1294
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1295
|
+
suite(snap, field).done((result) => {
|
|
1296
|
+
const errors = result.getErrors()[field];
|
|
1297
|
+
const warnings = result.getWarnings()[field];
|
|
1298
|
+
// Store warnings in the fieldWarnings signal for access by control wrappers.
|
|
1299
|
+
// This is necessary because Angular marks a field as invalid when control.errors !== null.
|
|
1300
|
+
// By storing warnings separately, fields can remain valid while still displaying warnings.
|
|
1301
|
+
this.fieldWarnings.update((map) => {
|
|
1302
|
+
const newMap = new Map(map);
|
|
1303
|
+
if (warnings?.length) {
|
|
1304
|
+
newMap.set(field, warnings);
|
|
1305
|
+
}
|
|
1306
|
+
else {
|
|
1307
|
+
newMap.delete(field);
|
|
1308
|
+
}
|
|
1309
|
+
return newMap;
|
|
1310
|
+
});
|
|
1311
|
+
// Build the validation result:
|
|
1312
|
+
// - Errors exist → return { errors, warnings? } (field invalid, Angular shows ng-invalid)
|
|
1313
|
+
// - Only warnings → return null (field valid, warnings accessed via fieldWarnings signal)
|
|
1314
|
+
// - Neither → return null (field valid)
|
|
1315
|
+
//
|
|
1316
|
+
// When errors exist, we also include warnings in control.errors for backwards compatibility
|
|
1317
|
+
// with code that reads warnings from control.errors.warnings.
|
|
1318
|
+
const out = errors?.length
|
|
1319
|
+
? {
|
|
1320
|
+
errors,
|
|
1321
|
+
...(warnings?.length && { warnings }),
|
|
1322
|
+
}
|
|
1323
|
+
: null;
|
|
1324
|
+
// CRITICAL: Ensure DOM validity classes update for OnPush components.
|
|
1325
|
+
//
|
|
1326
|
+
// Angular's template-driven forms update `ng-valid`/`ng-invalid` host classes
|
|
1327
|
+
// during change detection. When async validation completes, there may be no
|
|
1328
|
+
// follow-up change detection pass for OnPush hosts, leaving the DOM in a stale
|
|
1329
|
+
// visual state (even though the control status has updated).
|
|
1330
|
+
//
|
|
1331
|
+
// We schedule a detectChanges() on the next microtask to avoid calling it
|
|
1332
|
+
// synchronously inside Angular's own validation pipeline.
|
|
1333
|
+
queueMicrotask(() => {
|
|
1334
|
+
try {
|
|
1335
|
+
this.cdr.detectChanges();
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
// Fallback: mark for check when immediate detectChanges isn't safe.
|
|
1339
|
+
// This keeps behavior resilient in edge cases.
|
|
1340
|
+
this.cdr.markForCheck();
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
observer.next(out);
|
|
1344
|
+
observer.complete();
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
observer.next({ vestInternalError: 'Validation failed' });
|
|
1349
|
+
observer.complete();
|
|
1350
|
+
}
|
|
1351
|
+
})), catchError(() => of({ vestInternalError: 'Validation failed' })), take(1));
|
|
1352
|
+
};
|
|
1635
1353
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1354
|
+
/**
|
|
1355
|
+
* Set up validation config reactively using v2 pattern with toObservable + switchMap.
|
|
1356
|
+
* This provides automatic cleanup when config changes.
|
|
1357
|
+
*/
|
|
1358
|
+
#setupValidationConfig() {
|
|
1359
|
+
const form = this.ngForm.form;
|
|
1360
|
+
toObservable(this.validationConfig)
|
|
1361
|
+
.pipe(distinctUntilChanged(), switchMap((config) => {
|
|
1362
|
+
// Cast to the expected type for the helper method
|
|
1363
|
+
const typedConfig = config;
|
|
1364
|
+
return this.#createValidationStreams(form, typedConfig);
|
|
1365
|
+
}), takeUntilDestroyed(this.destroyRef))
|
|
1366
|
+
.subscribe();
|
|
1639
1367
|
}
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1368
|
+
/**
|
|
1369
|
+
* Creates validation streams for the provided configuration.
|
|
1370
|
+
* Returns EMPTY if config is null/undefined, otherwise merges all trigger field streams.
|
|
1371
|
+
*
|
|
1372
|
+
* @param form - The NgForm instance
|
|
1373
|
+
* @param config - The validation configuration mapping trigger fields to dependent fields
|
|
1374
|
+
* @returns Observable that emits when any trigger field changes and dependent fields need validation
|
|
1375
|
+
*/
|
|
1376
|
+
#createValidationStreams(form, config) {
|
|
1377
|
+
if (!config) {
|
|
1378
|
+
this.validationInProgress.clear();
|
|
1379
|
+
return EMPTY;
|
|
1650
1380
|
}
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
return
|
|
1656
|
-
}
|
|
1657
|
-
// Handle RegExp objects
|
|
1658
|
-
if (obj1 instanceof RegExp) {
|
|
1659
|
-
return (obj2 instanceof RegExp &&
|
|
1660
|
-
obj1.source === obj2.source &&
|
|
1661
|
-
obj1.flags === obj2.flags);
|
|
1381
|
+
const streams = Object.keys(config).map((triggerField) => {
|
|
1382
|
+
const dependents = config[triggerField] || [];
|
|
1383
|
+
return this.#createTriggerStream(form, triggerField, dependents);
|
|
1384
|
+
});
|
|
1385
|
+
return streams.length > 0 ? merge(...streams) : EMPTY;
|
|
1662
1386
|
}
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1387
|
+
/**
|
|
1388
|
+
* Creates a stream for a single trigger field that revalidates its dependent fields.
|
|
1389
|
+
*
|
|
1390
|
+
* This method handles:
|
|
1391
|
+
* 1. Waiting for the trigger control to exist in the form (for @if scenarios)
|
|
1392
|
+
* 2. Listening to value changes with debouncing
|
|
1393
|
+
* 3. Waiting for form to be idle before triggering dependents
|
|
1394
|
+
* 4. Waiting for all dependent controls to exist
|
|
1395
|
+
* 5. Updating dependent field validity with loop prevention
|
|
1396
|
+
* 6. Touch state propagation from trigger to dependents
|
|
1397
|
+
*
|
|
1398
|
+
* @param form - The NgForm instance
|
|
1399
|
+
* @param triggerField - Field path that triggers validation (e.g., 'password')
|
|
1400
|
+
* @param dependents - Array of dependent field paths to revalidate (e.g., ['confirmPassword'])
|
|
1401
|
+
* @returns Observable that completes after dependent fields are validated
|
|
1402
|
+
*/
|
|
1403
|
+
#createTriggerStream(form, triggerField, dependents) {
|
|
1404
|
+
// Wait for trigger control to exist, then stop listening (take(1) prevents feedback loops)
|
|
1405
|
+
const triggerControl$ = form.statusChanges.pipe(startWith(form.status), map(() => form.get(triggerField)), filter((c) => !!c),
|
|
1406
|
+
// CRITICAL: take(1) to stop listening after control is found
|
|
1407
|
+
// Without this, the pipeline continues to listen to statusChanges,
|
|
1408
|
+
// creating a feedback loop where validation triggers re-trigger the pipeline
|
|
1409
|
+
take(1));
|
|
1410
|
+
return triggerControl$.pipe(switchMap((control) => {
|
|
1411
|
+
return control.valueChanges.pipe(
|
|
1412
|
+
// CRITICAL: Filter out changes when this field is being validated by another field's config
|
|
1413
|
+
// This prevents circular triggers in bidirectional validationConfig
|
|
1414
|
+
filter(() => !this.validationInProgress.has(triggerField)), debounceTime(this.configDebounceTime), switchMap(() => {
|
|
1415
|
+
return this.#waitForFormIdle(form, control);
|
|
1416
|
+
}), switchMap(() => this.#waitForDependentControls(form, dependents, control)), tap(() => this.#updateDependentFields(form, control, triggerField, dependents)));
|
|
1417
|
+
}));
|
|
1674
1418
|
}
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1419
|
+
/**
|
|
1420
|
+
* Waits for the form to reach a non-PENDING state before proceeding.
|
|
1421
|
+
* This prevents validation race conditions where dependent field validation
|
|
1422
|
+
* triggers while the trigger field's validation is still running.
|
|
1423
|
+
*
|
|
1424
|
+
* If the form stays PENDING for longer than 2 seconds (e.g., slow async validators),
|
|
1425
|
+
* proceeds anyway to prevent blocking the validation pipeline.
|
|
1426
|
+
*
|
|
1427
|
+
* @param form - The NgForm instance
|
|
1428
|
+
* @param control - The trigger control to pass through
|
|
1429
|
+
* @returns Observable that emits the control once form is idle or timeout
|
|
1430
|
+
*/
|
|
1431
|
+
#waitForFormIdle(form, control) {
|
|
1432
|
+
// If form is already non-PENDING, return immediately
|
|
1433
|
+
if (form.status !== 'PENDING') {
|
|
1434
|
+
return of(control);
|
|
1685
1435
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
const keys2 = Object.keys(obj2);
|
|
1691
|
-
if (keys1.length !== keys2.length) {
|
|
1692
|
-
return false;
|
|
1436
|
+
// Form is PENDING, wait for it to become idle
|
|
1437
|
+
const idle$ = form.statusChanges.pipe(filter((s) => s !== 'PENDING'), take(1));
|
|
1438
|
+
const timeout$ = timer(2000);
|
|
1439
|
+
return race(idle$, timeout$).pipe(map(() => control));
|
|
1693
1440
|
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1441
|
+
/**
|
|
1442
|
+
* Waits for all dependent controls to exist in the form.
|
|
1443
|
+
* This handles @if scenarios where controls are conditionally rendered.
|
|
1444
|
+
*
|
|
1445
|
+
* @param form - The NgForm instance
|
|
1446
|
+
* @param dependents - Array of dependent field paths
|
|
1447
|
+
* @param control - The trigger control to pass through
|
|
1448
|
+
* @returns Observable that emits the control once all dependents exist
|
|
1449
|
+
*/
|
|
1450
|
+
#waitForDependentControls(form, dependents, control) {
|
|
1451
|
+
const allDependentsExist = dependents.every((depField) => !!form.get(depField));
|
|
1452
|
+
if (allDependentsExist) {
|
|
1453
|
+
return of(control);
|
|
1698
1454
|
}
|
|
1455
|
+
// Wait for dependent controls to be added to the form
|
|
1456
|
+
return form.statusChanges.pipe(startWith(form.status), filter(() => dependents.every((depField) => !!form.get(depField))), take(1), map(() => control));
|
|
1699
1457
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1458
|
+
/**
|
|
1459
|
+
* Updates validation for all dependent fields.
|
|
1460
|
+
*
|
|
1461
|
+
* Handles:
|
|
1462
|
+
* - Touch state propagation (mark dependents touched when trigger is touched)
|
|
1463
|
+
* - Loop prevention via validationInProgress set
|
|
1464
|
+
* - Silent validation (emitEvent: false) to prevent feedback loops
|
|
1465
|
+
*
|
|
1466
|
+
* @param form - The NgForm instance
|
|
1467
|
+
* @param control - The trigger control
|
|
1468
|
+
* @param triggerField - Field path of the trigger
|
|
1469
|
+
* @param dependents - Array of dependent field paths to update
|
|
1470
|
+
*/
|
|
1471
|
+
#updateDependentFields(form, control, triggerField, dependents) {
|
|
1472
|
+
// Mark trigger field as in-progress to prevent it from being re-triggered
|
|
1473
|
+
this.validationInProgress.add(triggerField);
|
|
1474
|
+
for (const depField of dependents) {
|
|
1475
|
+
const dependentControl = form.get(depField);
|
|
1476
|
+
if (!dependentControl) {
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
// Only validate if not already in progress (prevents bidirectional loops)
|
|
1480
|
+
if (!this.validationInProgress.has(depField)) {
|
|
1481
|
+
// CRITICAL: Mark the dependent field as in-progress BEFORE calling updateValueAndValidity
|
|
1482
|
+
// This prevents the dependent field's valueChanges from triggering its own validationConfig
|
|
1483
|
+
this.validationInProgress.add(depField);
|
|
1484
|
+
// Propagate touch state BEFORE validation to avoid duplicate class updates
|
|
1485
|
+
// markAsTouched() triggers change detection, so we do it once before updateValueAndValidity
|
|
1486
|
+
if (control.touched && !dependentControl.touched) {
|
|
1487
|
+
dependentControl.markAsTouched({ onlySelf: true });
|
|
1488
|
+
}
|
|
1489
|
+
// emitEvent: true is REQUIRED for async validators to actually run
|
|
1490
|
+
// The validationInProgress Set prevents infinite loops:
|
|
1491
|
+
// 1. Field A changes → triggers validation on dependent field B
|
|
1492
|
+
// 2. B is added to validationInProgress Set
|
|
1493
|
+
// 3. B's statusChanges emits → #handleValueChange checks validationInProgress
|
|
1494
|
+
// 4. Since B is in validationInProgress, its validationConfig is not triggered
|
|
1495
|
+
// 5. After 500ms timeout, B is removed from validationInProgress
|
|
1496
|
+
// This way:
|
|
1497
|
+
// - Async validators CAN run (emitEvent: true)
|
|
1498
|
+
// - BUT circular triggers are prevented (validationInProgress check)
|
|
1499
|
+
dependentControl.updateValueAndValidity({
|
|
1500
|
+
onlySelf: true,
|
|
1501
|
+
emitEvent: true, // Changed from false - REQUIRED for validators to run!
|
|
1502
|
+
});
|
|
1503
|
+
// CRITICAL: Force immediate change detection for OnPush components
|
|
1504
|
+
// updateValueAndValidity updates the control's status, but doesn't automatically
|
|
1505
|
+
// trigger change detection. Components using OnPush won't see the ng-invalid class
|
|
1506
|
+
// update in the DOM without this. Using detectChanges() instead of markForCheck()
|
|
1507
|
+
// to force immediate synchronous update rather than waiting for next CD cycle.
|
|
1508
|
+
this.cdr.detectChanges();
|
|
1751
1509
|
}
|
|
1752
|
-
// Recurse into nested object
|
|
1753
|
-
validateFormValueAgainstShape(value, shapeValue ?? {}, fieldPath);
|
|
1754
|
-
continue;
|
|
1755
|
-
}
|
|
1756
|
-
// Extra property: key exists in formValue but not in shape (likely a typo)
|
|
1757
|
-
if (!isNumericKey && shape && !(shapeKey in shape)) {
|
|
1758
|
-
logWarning(NGX_VEST_FORMS_ERRORS.EXTRA_PROPERTY, fieldPath);
|
|
1759
1510
|
}
|
|
1511
|
+
// Keep fields marked as in-progress for a short time to prevent immediate re-triggering
|
|
1512
|
+
// Use setTimeout to ensure async validators have time to complete before allowing new triggers
|
|
1513
|
+
setTimeout(() => {
|
|
1514
|
+
this.validationInProgress.delete(triggerField);
|
|
1515
|
+
for (const depField of dependents) {
|
|
1516
|
+
this.validationInProgress.delete(depField);
|
|
1517
|
+
}
|
|
1518
|
+
}, VALIDATION_IN_PROGRESS_TIMEOUT_MS);
|
|
1760
1519
|
}
|
|
1520
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
1521
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", 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" }, exportAs: ["scVestForm", "ngxVestForm"], ngImport: i0 }); }
|
|
1761
1522
|
}
|
|
1523
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormDirective, decorators: [{
|
|
1524
|
+
type: Directive,
|
|
1525
|
+
args: [{
|
|
1526
|
+
selector: 'form[scVestForm], form[ngxVestForm]',
|
|
1527
|
+
exportAs: 'scVestForm, ngxVestForm',
|
|
1528
|
+
}]
|
|
1529
|
+
}], 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"] }] } });
|
|
1762
1530
|
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
* </form>
|
|
1779
|
-
* ```
|
|
1780
|
-
*
|
|
1781
|
-
* ### With Validation Dependencies
|
|
1782
|
-
* ```html
|
|
1783
|
-
* <form ngxVestForm [suite]="suite" [validationConfig]="validationConfig">
|
|
1784
|
-
* <input name="password" [ngModel]="formValue().password" />
|
|
1785
|
-
* <input name="confirmPassword" [ngModel]="formValue().confirmPassword" />
|
|
1786
|
-
* </form>
|
|
1787
|
-
* ```
|
|
1788
|
-
* ```typescript
|
|
1789
|
-
* validationConfig = { 'password': ['confirmPassword'] };
|
|
1790
|
-
* ```
|
|
1791
|
-
*
|
|
1792
|
-
* ### Accessing Form State
|
|
1793
|
-
* ```typescript
|
|
1794
|
-
* vestForm = viewChild.required('vestForm', { read: FormDirective });
|
|
1795
|
-
* isValid = computed(() => this.vestForm().formState().valid);
|
|
1796
|
-
* ```
|
|
1797
|
-
*
|
|
1798
|
-
* @see {@link https://github.com/ngx-vest-forms/ngx-vest-forms} for full documentation
|
|
1799
|
-
* @publicApi
|
|
1800
|
-
*/
|
|
1801
|
-
class FormDirective {
|
|
1802
|
-
// Track last linked value to prevent unnecessary updates
|
|
1803
|
-
#lastLinkedValue;
|
|
1804
|
-
#lastSyncedFormValue;
|
|
1805
|
-
#lastSyncedModelValue;
|
|
1806
|
-
// Internal signal tracking form value changes via statusChanges
|
|
1807
|
-
#value;
|
|
1531
|
+
const INITIAL_FORM_CONTROL_STATE = {
|
|
1532
|
+
status: 'INVALID',
|
|
1533
|
+
isValid: false,
|
|
1534
|
+
isInvalid: true,
|
|
1535
|
+
isPending: false,
|
|
1536
|
+
isDisabled: false,
|
|
1537
|
+
isTouched: false,
|
|
1538
|
+
isDirty: false,
|
|
1539
|
+
isPristine: true,
|
|
1540
|
+
errors: null,
|
|
1541
|
+
};
|
|
1542
|
+
class FormControlStateDirective {
|
|
1543
|
+
#hostNgModel;
|
|
1544
|
+
#hostNgModelGroup;
|
|
1545
|
+
#injector;
|
|
1808
1546
|
/**
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1547
|
+
* Reference to the parent FormDirective for accessing field warnings.
|
|
1548
|
+
* Optional to support usage outside of ngxVestForm context.
|
|
1811
1549
|
*/
|
|
1812
|
-
#
|
|
1550
|
+
#formDirective;
|
|
1551
|
+
/**
|
|
1552
|
+
* Computed signal for the active control (NgModel or NgModelGroup)
|
|
1553
|
+
*/
|
|
1554
|
+
#activeControl;
|
|
1555
|
+
/**
|
|
1556
|
+
* Consolidated internal signal for interaction state tracking.
|
|
1557
|
+
* Combines touched, dirty, and hasBeenValidated into a single signal
|
|
1558
|
+
* to reduce signal overhead and simplify state updates.
|
|
1559
|
+
*/
|
|
1560
|
+
#interactionState;
|
|
1561
|
+
/**
|
|
1562
|
+
* Track the previous status to detect actual status changes (not just status emissions).
|
|
1563
|
+
* This helps distinguish between initial control creation and actual re-validation.
|
|
1564
|
+
*/
|
|
1565
|
+
#previousStatus;
|
|
1566
|
+
/**
|
|
1567
|
+
* Internal signal for control state (updated reactively)
|
|
1568
|
+
*/
|
|
1569
|
+
#controlStateSignal;
|
|
1570
|
+
constructor() {
|
|
1571
|
+
this.contentNgModel = contentChild(NgModel, ...(ngDevMode ? [{ debugName: "contentNgModel" }] : []));
|
|
1572
|
+
this.contentNgModelGroup = contentChild(NgModelGroup, ...(ngDevMode ? [{ debugName: "contentNgModelGroup" }] : []));
|
|
1573
|
+
this.#hostNgModel = inject(NgModel, { self: true, optional: true });
|
|
1574
|
+
this.#hostNgModelGroup = inject(NgModelGroup, {
|
|
1575
|
+
self: true,
|
|
1576
|
+
optional: true,
|
|
1577
|
+
});
|
|
1578
|
+
this.#injector = inject(Injector);
|
|
1579
|
+
/**
|
|
1580
|
+
* Reference to the parent FormDirective for accessing field warnings.
|
|
1581
|
+
* Optional to support usage outside of ngxVestForm context.
|
|
1582
|
+
*/
|
|
1583
|
+
this.#formDirective = inject(FormDirective, { optional: true });
|
|
1584
|
+
/**
|
|
1585
|
+
* Computed signal for the active control (NgModel or NgModelGroup)
|
|
1586
|
+
*/
|
|
1587
|
+
this.#activeControl = computed(() => this.#hostNgModel ||
|
|
1588
|
+
this.#hostNgModelGroup ||
|
|
1589
|
+
this.contentNgModel() ||
|
|
1590
|
+
this.contentNgModelGroup() ||
|
|
1591
|
+
null, ...(ngDevMode ? [{ debugName: "#activeControl" }] : []));
|
|
1592
|
+
/**
|
|
1593
|
+
* Consolidated internal signal for interaction state tracking.
|
|
1594
|
+
* Combines touched, dirty, and hasBeenValidated into a single signal
|
|
1595
|
+
* to reduce signal overhead and simplify state updates.
|
|
1596
|
+
*/
|
|
1597
|
+
this.#interactionState = signal({
|
|
1598
|
+
isTouched: false,
|
|
1599
|
+
isDirty: false,
|
|
1600
|
+
hasBeenValidated: false,
|
|
1601
|
+
}, ...(ngDevMode ? [{ debugName: "#interactionState" }] : []));
|
|
1602
|
+
/**
|
|
1603
|
+
* Track the previous status to detect actual status changes (not just status emissions).
|
|
1604
|
+
* This helps distinguish between initial control creation and actual re-validation.
|
|
1605
|
+
*/
|
|
1606
|
+
this.#previousStatus = null;
|
|
1607
|
+
/**
|
|
1608
|
+
* Internal signal for control state (updated reactively)
|
|
1609
|
+
*/
|
|
1610
|
+
this.#controlStateSignal = signal(INITIAL_FORM_CONTROL_STATE, ...(ngDevMode ? [{ debugName: "#controlStateSignal" }] : []));
|
|
1611
|
+
/**
|
|
1612
|
+
* Main control state computed signal (merges robust touched/dirty)
|
|
1613
|
+
*/
|
|
1614
|
+
this.controlState = computed(() => this.#controlStateSignal(), ...(ngDevMode ? [{ debugName: "controlState" }] : []));
|
|
1615
|
+
/**
|
|
1616
|
+
* Extracts error messages from Angular/Vest errors (recursively flattens)
|
|
1617
|
+
*/
|
|
1618
|
+
this.errorMessages = computed(() => {
|
|
1619
|
+
const { errors } = this.controlState();
|
|
1620
|
+
if (!errors)
|
|
1621
|
+
return [];
|
|
1622
|
+
// Vest errors are stored in the 'errors' property as an array
|
|
1623
|
+
const vestErrors = errors.errors;
|
|
1624
|
+
if (vestErrors) {
|
|
1625
|
+
return vestErrors
|
|
1626
|
+
.map((error) => normalizeErrorMessage(error))
|
|
1627
|
+
.filter((error) => Boolean(error));
|
|
1628
|
+
}
|
|
1629
|
+
// Fallback to flattened Angular error keys, excluding 'warnings' key
|
|
1630
|
+
// to prevent warnings from appearing in the error list
|
|
1631
|
+
const errorsWithoutWarnings = { ...errors };
|
|
1632
|
+
delete errorsWithoutWarnings['warnings'];
|
|
1633
|
+
return this.#flattenAngularErrors(errorsWithoutWarnings);
|
|
1634
|
+
}, ...(ngDevMode ? [{ debugName: "errorMessages" }] : []));
|
|
1635
|
+
/**
|
|
1636
|
+
* ADVANCED: updateOn strategy (change/blur/submit) if available
|
|
1637
|
+
*/
|
|
1638
|
+
this.updateOn = computed(() => {
|
|
1639
|
+
const ngModel = this.contentNgModel() || this.#hostNgModel;
|
|
1640
|
+
return ngModel?.options?.updateOn ?? 'change';
|
|
1641
|
+
}, ...(ngDevMode ? [{ debugName: "updateOn" }] : []));
|
|
1642
|
+
/**
|
|
1643
|
+
* ADVANCED: Composite/derived signals for advanced error display logic
|
|
1644
|
+
*/
|
|
1645
|
+
this.isValidTouched = computed(() => this.isValid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isValidTouched" }] : []));
|
|
1646
|
+
this.isInvalidTouched = computed(() => this.isInvalid() && this.isTouched(), ...(ngDevMode ? [{ debugName: "isInvalidTouched" }] : []));
|
|
1647
|
+
this.shouldShowErrors = computed(() => this.isInvalid() && this.isTouched() && !this.isPending(), ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
|
|
1648
|
+
/**
|
|
1649
|
+
* Extracts warning messages from Vest validation results.
|
|
1650
|
+
* Checks two sources:
|
|
1651
|
+
* 1. control.errors.warnings (when errors exist alongside warnings)
|
|
1652
|
+
* 2. FormDirective.fieldWarnings (for warnings-only scenarios)
|
|
1653
|
+
* This dual-source approach allows warnings to be displayed without affecting field validity.
|
|
1654
|
+
*/
|
|
1655
|
+
this.warningMessages = computed(() => {
|
|
1656
|
+
// Source 1: warnings from control.errors (when field has errors)
|
|
1657
|
+
const { errors } = this.controlState();
|
|
1658
|
+
// Always read fieldWarnings signal to ensure reactive tracking
|
|
1659
|
+
// This must be read unconditionally for proper signal dependency tracking
|
|
1660
|
+
const fieldWarnings = this.#formDirective?.fieldWarnings();
|
|
1661
|
+
const activeControl = this.#activeControl();
|
|
1662
|
+
// If we have warnings in control.errors, use those (errors+warnings case)
|
|
1663
|
+
if (errors?.warnings) {
|
|
1664
|
+
return [...errors.warnings];
|
|
1665
|
+
}
|
|
1666
|
+
// Source 2: warnings from FormDirective (for warnings-only scenarios)
|
|
1667
|
+
// When a field only has warnings (no errors), they're stored in fieldWarnings
|
|
1668
|
+
// to keep the field valid while still allowing warnings to be displayed.
|
|
1669
|
+
if (fieldWarnings && activeControl) {
|
|
1670
|
+
// Get the field path from the control's path property
|
|
1671
|
+
// NgModel.path returns an array like ['passwords', 'password'] which needs to be joined
|
|
1672
|
+
const path = activeControl.path;
|
|
1673
|
+
if (path?.length) {
|
|
1674
|
+
const fieldPath = path.join('.');
|
|
1675
|
+
const warnings = fieldWarnings.get(fieldPath);
|
|
1676
|
+
if (warnings) {
|
|
1677
|
+
return [...warnings];
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return [];
|
|
1682
|
+
}, ...(ngDevMode ? [{ debugName: "warningMessages" }] : []));
|
|
1683
|
+
/**
|
|
1684
|
+
* Whether async validation is in progress
|
|
1685
|
+
*/
|
|
1686
|
+
this.hasPendingValidation = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "hasPendingValidation" }] : []));
|
|
1687
|
+
/**
|
|
1688
|
+
* Convenience signals for common state checks
|
|
1689
|
+
*/
|
|
1690
|
+
this.isValid = computed(() => this.controlState().isValid, ...(ngDevMode ? [{ debugName: "isValid" }] : []));
|
|
1691
|
+
this.isInvalid = computed(() => this.controlState().isInvalid, ...(ngDevMode ? [{ debugName: "isInvalid" }] : []));
|
|
1692
|
+
this.isPending = computed(() => this.controlState().isPending, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
1693
|
+
this.isTouched = computed(() => this.controlState().isTouched, ...(ngDevMode ? [{ debugName: "isTouched" }] : []));
|
|
1694
|
+
this.isDirty = computed(() => this.controlState().isDirty, ...(ngDevMode ? [{ debugName: "isDirty" }] : []));
|
|
1695
|
+
this.isPristine = computed(() => this.controlState().isPristine, ...(ngDevMode ? [{ debugName: "isPristine" }] : []));
|
|
1696
|
+
this.isDisabled = computed(() => this.controlState().isDisabled, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
|
|
1697
|
+
this.hasErrors = computed(() => this.errorMessages().length > 0, ...(ngDevMode ? [{ debugName: "hasErrors" }] : []));
|
|
1698
|
+
/**
|
|
1699
|
+
* Whether this control has been validated at least once.
|
|
1700
|
+
* True after the first validation completes, even if the user hasn't touched the field.
|
|
1701
|
+
* This enables showing errors for validationConfig-triggered validations.
|
|
1702
|
+
*/
|
|
1703
|
+
this.hasBeenValidated = computed(() => this.#interactionState().hasBeenValidated, ...(ngDevMode ? [{ debugName: "hasBeenValidated" }] : []));
|
|
1704
|
+
// Update control state reactively with proper cleanup
|
|
1705
|
+
effect((onCleanup) => {
|
|
1706
|
+
const control = this.#activeControl();
|
|
1707
|
+
const interaction = this.#interactionState();
|
|
1708
|
+
if (!control) {
|
|
1709
|
+
this.#controlStateSignal.set(INITIAL_FORM_CONTROL_STATE);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
// Listen to control changes
|
|
1713
|
+
const sub = control.control?.statusChanges?.subscribe(() => {
|
|
1714
|
+
const { status, valid, invalid, pending, disabled, pristine, errors, touched, dirty, } = control;
|
|
1715
|
+
const currentStatus = status;
|
|
1716
|
+
// Mark as validated when any of the following conditions are met:
|
|
1717
|
+
// 1. The control has been touched (user blurred the field).
|
|
1718
|
+
// 2. The control's status has actually changed (not the first status emission),
|
|
1719
|
+
// AND the new status is not 'PENDING' (validation completed),
|
|
1720
|
+
// AND the control has been interacted with (dirty).
|
|
1721
|
+
//
|
|
1722
|
+
// This ensures hasBeenValidated is true for:
|
|
1723
|
+
// - User blur events (touched becomes true)
|
|
1724
|
+
// - User-triggered validations (dirty)
|
|
1725
|
+
// - ValidationConfig-triggered validations that result in the control becoming touched
|
|
1726
|
+
// But NOT for initial page load validations.
|
|
1727
|
+
//
|
|
1728
|
+
// Accessibility: The logic is structured for clarity and maintainability.
|
|
1729
|
+
// IMPORTANT: Read touched/dirty directly from control, not from signal,
|
|
1730
|
+
// to avoid race conditions with afterEveryRender sync.
|
|
1731
|
+
if (touched || // Control was blurred (most common case)
|
|
1732
|
+
(this.#previousStatus !== null && // Not the first status emission
|
|
1733
|
+
this.#previousStatus !== currentStatus && // Status actually changed
|
|
1734
|
+
currentStatus !== null &&
|
|
1735
|
+
currentStatus !== 'PENDING' &&
|
|
1736
|
+
dirty) // Or control value changed (typed)
|
|
1737
|
+
) {
|
|
1738
|
+
this.#interactionState.update((state) => ({
|
|
1739
|
+
...state,
|
|
1740
|
+
hasBeenValidated: true,
|
|
1741
|
+
}));
|
|
1742
|
+
}
|
|
1743
|
+
// Track current status for next iteration
|
|
1744
|
+
this.#previousStatus = currentStatus;
|
|
1745
|
+
this.#controlStateSignal.set({
|
|
1746
|
+
status: currentStatus,
|
|
1747
|
+
isValid: valid ?? false,
|
|
1748
|
+
isInvalid: invalid ?? false,
|
|
1749
|
+
isPending: pending ?? false,
|
|
1750
|
+
isDisabled: disabled ?? false,
|
|
1751
|
+
isTouched: interaction.isTouched,
|
|
1752
|
+
isDirty: interaction.isDirty,
|
|
1753
|
+
isPristine: pristine ?? true,
|
|
1754
|
+
errors: errors,
|
|
1755
|
+
});
|
|
1756
|
+
});
|
|
1757
|
+
// Initial update
|
|
1758
|
+
const { status, valid, invalid, pending, disabled, pristine, errors } = control;
|
|
1759
|
+
const initialStatus = status;
|
|
1760
|
+
this.#previousStatus = initialStatus;
|
|
1761
|
+
this.#controlStateSignal.set({
|
|
1762
|
+
status: initialStatus,
|
|
1763
|
+
isValid: valid ?? false,
|
|
1764
|
+
isInvalid: invalid ?? false,
|
|
1765
|
+
isPending: pending ?? false,
|
|
1766
|
+
isDisabled: disabled ?? false,
|
|
1767
|
+
isTouched: interaction.isTouched,
|
|
1768
|
+
isDirty: interaction.isDirty,
|
|
1769
|
+
isPristine: pristine ?? true,
|
|
1770
|
+
errors: errors,
|
|
1771
|
+
});
|
|
1772
|
+
// Proper cleanup using onCleanup callback (Angular 21 best practice)
|
|
1773
|
+
onCleanup(() => sub?.unsubscribe());
|
|
1774
|
+
});
|
|
1775
|
+
// Robustly sync touched/dirty/pending after every render (Angular 18+ best practice)
|
|
1776
|
+
// This handles cases where statusChanges events are missed or delayed
|
|
1777
|
+
afterEveryRender(() => {
|
|
1778
|
+
const control = this.#activeControl();
|
|
1779
|
+
if (control) {
|
|
1780
|
+
const currentInteraction = this.#interactionState();
|
|
1781
|
+
const currentState = this.#controlStateSignal();
|
|
1782
|
+
const newTouched = control.touched ?? false;
|
|
1783
|
+
const newDirty = control.dirty ?? false;
|
|
1784
|
+
// Sync interaction state (touched/dirty)
|
|
1785
|
+
if (newTouched !== currentInteraction.isTouched ||
|
|
1786
|
+
newDirty !== currentInteraction.isDirty) {
|
|
1787
|
+
// Mark as validated when control becomes touched (e.g., user blurred the field)
|
|
1788
|
+
// This handles the case where blur doesn't trigger statusChanges (field already invalid)
|
|
1789
|
+
const shouldMarkValidated = newTouched && !currentInteraction.isTouched;
|
|
1790
|
+
this.#interactionState.update((state) => ({
|
|
1791
|
+
...state,
|
|
1792
|
+
isTouched: newTouched,
|
|
1793
|
+
isDirty: newDirty,
|
|
1794
|
+
hasBeenValidated: shouldMarkValidated
|
|
1795
|
+
? true
|
|
1796
|
+
: state.hasBeenValidated,
|
|
1797
|
+
}));
|
|
1798
|
+
}
|
|
1799
|
+
// Sync pending state only when it transitions from true to false
|
|
1800
|
+
// This fixes "Validating..." being stuck when statusChanges misses the transition
|
|
1801
|
+
// We only sync when pending goes false to avoid interfering with async validation in progress
|
|
1802
|
+
const controlPending = control.pending ?? false;
|
|
1803
|
+
if (currentState.isPending && !controlPending) {
|
|
1804
|
+
// Pending changed from true to false - update the full state
|
|
1805
|
+
const newStatus = control.status;
|
|
1806
|
+
this.#controlStateSignal.set({
|
|
1807
|
+
status: newStatus,
|
|
1808
|
+
isPending: false,
|
|
1809
|
+
isValid: control.valid ?? false,
|
|
1810
|
+
isInvalid: control.invalid ?? false,
|
|
1811
|
+
isPristine: control.pristine ?? true,
|
|
1812
|
+
isDisabled: control.disabled ?? false,
|
|
1813
|
+
isTouched: newTouched,
|
|
1814
|
+
isDirty: newDirty,
|
|
1815
|
+
errors: control.errors,
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}, { injector: this.#injector });
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Recursively flattens Angular error objects into an array of error messages.
|
|
1823
|
+
* Handles string values, objects with a message property, and nested structures.
|
|
1824
|
+
*/
|
|
1825
|
+
#flattenAngularErrors(errors) {
|
|
1826
|
+
const result = [];
|
|
1827
|
+
for (const key of Object.keys(errors)) {
|
|
1828
|
+
const value = errors[key];
|
|
1829
|
+
if (typeof value === 'string') {
|
|
1830
|
+
// String values: push the value itself, not the key
|
|
1831
|
+
result.push(value);
|
|
1832
|
+
}
|
|
1833
|
+
else if (isErrorWithMessage(value)) {
|
|
1834
|
+
// Objects with a 'message' property: extract the message
|
|
1835
|
+
result.push(value.message);
|
|
1836
|
+
}
|
|
1837
|
+
else if (typeof value === 'object' && value !== null) {
|
|
1838
|
+
// Nested objects/arrays: recursively flatten
|
|
1839
|
+
result.push(...this.#flattenAngularErrors(value));
|
|
1840
|
+
}
|
|
1841
|
+
else {
|
|
1842
|
+
// Fallback: push the key for primitive types (backward compatibility)
|
|
1843
|
+
result.push(key);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return result;
|
|
1847
|
+
}
|
|
1848
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormControlStateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
1849
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.1", 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 }); }
|
|
1850
|
+
}
|
|
1851
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormControlStateDirective, decorators: [{
|
|
1852
|
+
type: Directive,
|
|
1853
|
+
args: [{
|
|
1854
|
+
selector: '[formControlState], [ngxControlState]',
|
|
1855
|
+
exportAs: 'formControlState, ngxControlState',
|
|
1856
|
+
}]
|
|
1857
|
+
}], ctorParameters: () => [], propDecorators: { contentNgModel: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModel), { isSignal: true }] }], contentNgModelGroup: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgModelGroup), { isSignal: true }] }] } });
|
|
1858
|
+
/**
|
|
1859
|
+
* Type guard for objects with a message property
|
|
1860
|
+
*/
|
|
1861
|
+
function isErrorWithMessage(value) {
|
|
1862
|
+
return (typeof value === 'object' &&
|
|
1863
|
+
value !== null &&
|
|
1864
|
+
'message' in value &&
|
|
1865
|
+
typeof value.message === 'string');
|
|
1866
|
+
}
|
|
1867
|
+
function normalizeErrorMessage(value) {
|
|
1868
|
+
if (typeof value === 'string') {
|
|
1869
|
+
return value;
|
|
1870
|
+
}
|
|
1871
|
+
if (isErrorWithMessage(value)) {
|
|
1872
|
+
return value.message;
|
|
1873
|
+
}
|
|
1874
|
+
if (value === null || value === undefined) {
|
|
1875
|
+
return null;
|
|
1876
|
+
}
|
|
1877
|
+
if (typeof value === 'object') {
|
|
1878
|
+
try {
|
|
1879
|
+
return JSON.stringify(value);
|
|
1880
|
+
}
|
|
1881
|
+
catch {
|
|
1882
|
+
return String(value);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
return String(value);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const SC_ERROR_DISPLAY_MODE_DEFAULT = 'on-blur-or-submit';
|
|
1889
|
+
const SC_WARNING_DISPLAY_MODE_DEFAULT = 'on-validated-or-touch';
|
|
1890
|
+
class FormErrorDisplayDirective {
|
|
1891
|
+
#formControlState;
|
|
1892
|
+
// Optionally inject NgForm for form submission tracking
|
|
1893
|
+
#ngForm;
|
|
1813
1894
|
/**
|
|
1814
|
-
*
|
|
1895
|
+
* Internal trigger signal that updates whenever form submit or status changes.
|
|
1896
|
+
* Used to ensure reactive tracking for the formSubmitted computed signal.
|
|
1815
1897
|
*/
|
|
1816
|
-
#
|
|
1898
|
+
#formEventTrigger;
|
|
1817
1899
|
constructor() {
|
|
1818
|
-
this
|
|
1819
|
-
|
|
1820
|
-
this
|
|
1821
|
-
this.configDebounceTime = inject(NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN);
|
|
1822
|
-
// Track last linked value to prevent unnecessary updates
|
|
1823
|
-
this.#lastLinkedValue = null;
|
|
1824
|
-
this.#lastSyncedFormValue = null;
|
|
1825
|
-
this.#lastSyncedModelValue = null;
|
|
1826
|
-
// Internal signal tracking form value changes via statusChanges
|
|
1827
|
-
this.#value = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
|
|
1828
|
-
/**
|
|
1829
|
-
* LinkedSignal that computes form values from Angular form state.
|
|
1830
|
-
* This eliminates timing issues with the previous dual-effect pattern.
|
|
1831
|
-
*/
|
|
1832
|
-
this.#formValueSignal = linkedSignal(() => {
|
|
1833
|
-
// Track form value changes
|
|
1834
|
-
this.#value();
|
|
1835
|
-
const raw = mergeValuesAndRawValues(this.ngForm.form);
|
|
1836
|
-
if (Object.keys(this.ngForm.form.controls).length > 0) {
|
|
1837
|
-
this.#lastLinkedValue = raw;
|
|
1838
|
-
return raw;
|
|
1839
|
-
}
|
|
1840
|
-
else if (this.#lastLinkedValue !== null) {
|
|
1841
|
-
return this.#lastLinkedValue;
|
|
1842
|
-
}
|
|
1843
|
-
return null;
|
|
1844
|
-
}, ...(ngDevMode ? [{ debugName: "#formValueSignal" }] : []));
|
|
1845
|
-
/**
|
|
1846
|
-
* Track the Angular form status as a signal for advanced status flags
|
|
1847
|
-
*/
|
|
1848
|
-
this.#statusSignal = toSignal(this.ngForm.form.statusChanges.pipe(startWith(this.ngForm.form.status)), { initialValue: this.ngForm.form.status });
|
|
1900
|
+
this.#formControlState = inject(FormControlStateDirective);
|
|
1901
|
+
// Optionally inject NgForm for form submission tracking
|
|
1902
|
+
this.#ngForm = inject(NgForm, { optional: true });
|
|
1849
1903
|
/**
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
1853
|
-
* Uses custom equality function to prevent unnecessary recalculations
|
|
1854
|
-
* when form status changes but actual values/errors remain the same.
|
|
1904
|
+
* Input signal for error display mode.
|
|
1905
|
+
* Works seamlessly with hostDirectives in Angular 19+.
|
|
1855
1906
|
*/
|
|
1856
|
-
this.
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
return {
|
|
1860
|
-
valid: this.ngForm.form.valid,
|
|
1861
|
-
errors: getAllFormErrors(this.ngForm.form),
|
|
1862
|
-
value: this.#formValueSignal(),
|
|
1863
|
-
};
|
|
1864
|
-
}, { ...(ngDevMode ? { debugName: "formState" } : {}), equal: (a, b) => {
|
|
1865
|
-
// Fast path: reference equality
|
|
1866
|
-
if (a === b)
|
|
1867
|
-
return true;
|
|
1868
|
-
// Null/undefined check
|
|
1869
|
-
if (!a || !b)
|
|
1870
|
-
return false;
|
|
1871
|
-
// Deep equality check for form state properties
|
|
1872
|
-
return (a.valid === b.valid &&
|
|
1873
|
-
fastDeepEqual(a.errors, b.errors) &&
|
|
1874
|
-
fastDeepEqual(a.value, b.value));
|
|
1875
|
-
} });
|
|
1907
|
+
this.errorDisplayMode = input(inject(NGX_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
1908
|
+
inject(SC_ERROR_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
1909
|
+
SC_ERROR_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "errorDisplayMode" }] : []));
|
|
1876
1910
|
/**
|
|
1877
|
-
*
|
|
1878
|
-
*
|
|
1879
|
-
* In the minimal core directive (form-core.directive.ts), this would be model() instead.
|
|
1911
|
+
* Input signal for warning display mode.
|
|
1912
|
+
* Controls whether warnings are shown only after touch or also after validation.
|
|
1880
1913
|
*/
|
|
1881
|
-
this.
|
|
1914
|
+
this.warningDisplayMode = input(inject(NGX_WARNING_DISPLAY_MODE_TOKEN, { optional: true }) ??
|
|
1915
|
+
SC_WARNING_DISPLAY_MODE_DEFAULT, ...(ngDevMode ? [{ debugName: "warningDisplayMode" }] : []));
|
|
1916
|
+
// Expose state signals from FormControlStateDirective
|
|
1917
|
+
this.controlState = this.#formControlState.controlState;
|
|
1918
|
+
this.errorMessages = this.#formControlState.errorMessages;
|
|
1919
|
+
this.warningMessages = this.#formControlState.warningMessages;
|
|
1920
|
+
this.hasPendingValidation = this.#formControlState.hasPendingValidation;
|
|
1921
|
+
this.isTouched = this.#formControlState.isTouched;
|
|
1922
|
+
this.isDirty = this.#formControlState.isDirty;
|
|
1923
|
+
this.isValid = this.#formControlState.isValid;
|
|
1924
|
+
this.isInvalid = this.#formControlState.isInvalid;
|
|
1925
|
+
this.hasBeenValidated = this.#formControlState.hasBeenValidated;
|
|
1882
1926
|
/**
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
* FormFieldName<T> (string literal union) being assignable to string.
|
|
1927
|
+
* Expose updateOn and formSubmitted as public signals for advanced consumers.
|
|
1928
|
+
* updateOn: The ngModelOptions.updateOn value for the control (change/blur/submit)
|
|
1929
|
+
* formSubmitted: true after the form is submitted (if NgForm is present)
|
|
1887
1930
|
*/
|
|
1888
|
-
this.
|
|
1931
|
+
this.updateOn = this.#formControlState.updateOn;
|
|
1889
1932
|
/**
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
1892
|
-
* contains values that shouldn't be there (typo's) that the developer gets run-time
|
|
1893
|
-
* errors in dev mode
|
|
1933
|
+
* Internal trigger signal that updates whenever form submit or status changes.
|
|
1934
|
+
* Used to ensure reactive tracking for the formSubmitted computed signal.
|
|
1894
1935
|
*/
|
|
1895
|
-
this
|
|
1936
|
+
this.#formEventTrigger = this.#ngForm
|
|
1937
|
+
? toSignal(merge(this.#ngForm.ngSubmit, this.#ngForm.statusChanges ?? of()).pipe(startWith(null)), { initialValue: null })
|
|
1938
|
+
: signal(null);
|
|
1896
1939
|
/**
|
|
1897
|
-
*
|
|
1898
|
-
* trigger validations on the dependant fields
|
|
1899
|
-
* Eg: ```typescript
|
|
1900
|
-
* validationConfig = {
|
|
1901
|
-
* 'passwords.password': ['passwords.confirmPassword']
|
|
1902
|
-
* }
|
|
1903
|
-
* ```
|
|
1904
|
-
*
|
|
1905
|
-
* This will trigger the updateValueAndValidity on passwords.confirmPassword every time the passwords.password gets a new value
|
|
1940
|
+
* Signal that tracks NgForm.submitted state reactively.
|
|
1906
1941
|
*
|
|
1907
|
-
*
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
this.pending$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v === 'PENDING'), distinctUntilChanged());
|
|
1911
|
-
/**
|
|
1912
|
-
* Emits every time the form status changes in a state
|
|
1913
|
-
* that is not PENDING
|
|
1914
|
-
* We need this to assure that the form is in 'idle' state
|
|
1915
|
-
*/
|
|
1916
|
-
this.idle$ = this.ngForm.form.events.pipe(filter((v) => v instanceof StatusChangeEvent), map((v) => v.status), filter((v) => v !== 'PENDING'), distinctUntilChanged());
|
|
1917
|
-
/**
|
|
1918
|
-
* Triggered as soon as the form value changes
|
|
1919
|
-
* It also contains the disabled values (raw values)
|
|
1942
|
+
* Uses a trigger signal pattern for cleaner reactive tracking:
|
|
1943
|
+
* - ngSubmit: fires when form is submitted (sets NgForm.submitted = true)
|
|
1944
|
+
* - statusChanges: fires after resetForm() (which sets NgForm.submitted = false)
|
|
1920
1945
|
*
|
|
1921
|
-
*
|
|
1946
|
+
* This ensures proper sync with both submit and reset operations.
|
|
1922
1947
|
*/
|
|
1923
|
-
this.
|
|
1924
|
-
//
|
|
1925
|
-
|
|
1926
|
-
|
|
1948
|
+
this.formSubmitted = computed(() => {
|
|
1949
|
+
// Trigger signal ensures this recomputes on submit/status changes
|
|
1950
|
+
this.#formEventTrigger();
|
|
1951
|
+
return this.#ngForm?.submitted ?? false;
|
|
1952
|
+
}, ...(ngDevMode ? [{ debugName: "formSubmitted" }] : []));
|
|
1927
1953
|
/**
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1954
|
+
* Determines if errors should be shown based on the specified display mode
|
|
1955
|
+
* and the control's state (touched/submitted/validated).
|
|
1930
1956
|
*
|
|
1931
|
-
*
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
* Triggered as soon as the form becomes dirty
|
|
1957
|
+
* Note: We check both hasErrors (extracted error messages) AND isInvalid (Angular's validation state)
|
|
1958
|
+
* because in some cases (like conditional validations via validationConfig), the control is marked
|
|
1959
|
+
* as invalid by Angular before error messages are extracted from Vest. This ensures aria-invalid
|
|
1960
|
+
* is set correctly even during the validation propagation delay.
|
|
1936
1961
|
*
|
|
1937
|
-
*
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
/**
|
|
1941
|
-
* Fired when the status of the root form changes.
|
|
1962
|
+
* For validationConfig-triggered validations: A field can be validated without being touched
|
|
1963
|
+
* (e.g., confirmPassword validated when password changes). We check hasBeenValidated to show
|
|
1964
|
+
* errors in these scenarios, providing better UX and proper ARIA attributes.
|
|
1942
1965
|
*/
|
|
1943
|
-
this.
|
|
1966
|
+
this.shouldShowErrors = computed(() => {
|
|
1967
|
+
const mode = this.errorDisplayMode();
|
|
1968
|
+
const isTouched = this.isTouched();
|
|
1969
|
+
const isInvalid = this.isInvalid();
|
|
1970
|
+
const hasErrors = this.errorMessages().length > 0;
|
|
1971
|
+
const updateOn = this.updateOn();
|
|
1972
|
+
const formSubmitted = this.formSubmitted();
|
|
1973
|
+
// Consider errors present if either we have error messages OR the control is invalid
|
|
1974
|
+
// This handles the race condition where Angular marks control invalid before Vest errors propagate
|
|
1975
|
+
const hasErrorState = hasErrors || isInvalid;
|
|
1976
|
+
// Always only show errors after submit if updateOn is 'submit'
|
|
1977
|
+
if (updateOn === 'submit') {
|
|
1978
|
+
return !!(formSubmitted && hasErrorState);
|
|
1979
|
+
}
|
|
1980
|
+
// on-blur: show errors after blur (touch)
|
|
1981
|
+
if (mode === 'on-blur') {
|
|
1982
|
+
return !!(isTouched && hasErrorState);
|
|
1983
|
+
}
|
|
1984
|
+
// on-submit: show errors after submit
|
|
1985
|
+
if (mode === 'on-submit') {
|
|
1986
|
+
return !!(formSubmitted && hasErrorState);
|
|
1987
|
+
}
|
|
1988
|
+
// on-blur-or-submit: show errors after blur (touch) OR submit
|
|
1989
|
+
return !!((isTouched || formSubmitted) && hasErrorState);
|
|
1990
|
+
}, ...(ngDevMode ? [{ debugName: "shouldShowErrors" }] : []));
|
|
1944
1991
|
/**
|
|
1945
|
-
*
|
|
1946
|
-
*
|
|
1947
|
-
* Cleanup is handled automatically by the directive when it's destroyed.
|
|
1992
|
+
* Errors to display (filtered for pending state)
|
|
1948
1993
|
*/
|
|
1949
|
-
this.
|
|
1994
|
+
this.errors = computed(() => {
|
|
1995
|
+
if (this.hasPendingValidation())
|
|
1996
|
+
return [];
|
|
1997
|
+
return this.errorMessages();
|
|
1998
|
+
}, ...(ngDevMode ? [{ debugName: "errors" }] : []));
|
|
1950
1999
|
/**
|
|
1951
|
-
*
|
|
2000
|
+
* Warnings to display (filtered for pending state)
|
|
1952
2001
|
*/
|
|
1953
|
-
this.
|
|
2002
|
+
this.warnings = computed(() => {
|
|
2003
|
+
if (this.hasPendingValidation())
|
|
2004
|
+
return [];
|
|
2005
|
+
return this.warningMessages();
|
|
2006
|
+
}, ...(ngDevMode ? [{ debugName: "warnings" }] : []));
|
|
1954
2007
|
/**
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
2008
|
+
* Whether the control is currently being validated (pending)
|
|
2009
|
+
* Excludes pristine+untouched controls to prevent "Validating..." on initial load
|
|
1957
2010
|
*/
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
2011
|
+
this.isPending = computed(() => {
|
|
2012
|
+
// Don't show pending state for pristine untouched controls
|
|
2013
|
+
// This prevents "Validating..." message appearing on initial page load
|
|
2014
|
+
const state = this.#formControlState.controlState();
|
|
2015
|
+
if (state.isPristine && !state.isTouched) {
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
return this.hasPendingValidation();
|
|
2019
|
+
}, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
|
|
2020
|
+
// Warn about problematic combinations of updateOn and errorDisplayMode
|
|
2021
|
+
effect(() => {
|
|
2022
|
+
const mode = this.errorDisplayMode();
|
|
2023
|
+
const updateOn = this.updateOn();
|
|
2024
|
+
if (updateOn === 'submit' && mode === 'on-blur') {
|
|
2025
|
+
console.warn('[ngx-vest-forms] Potential UX issue: errorDisplayMode is "on-blur" but updateOn is "submit". Errors will only show after form submission, not after blur.');
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorDisplayDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2030
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", 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 }); }
|
|
2031
|
+
}
|
|
2032
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorDisplayDirective, decorators: [{
|
|
2033
|
+
type: Directive,
|
|
2034
|
+
args: [{
|
|
2035
|
+
selector: '[formErrorDisplay], [ngxErrorDisplay]',
|
|
2036
|
+
exportAs: 'formErrorDisplay, ngxErrorDisplay',
|
|
2037
|
+
hostDirectives: [FormControlStateDirective],
|
|
2038
|
+
}]
|
|
2039
|
+
}], ctorParameters: () => [], propDecorators: { errorDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorDisplayMode", required: false }] }], warningDisplayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "warningDisplayMode", required: false }] }] } });
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Creates a debounced pending state signal that prevents flashing validation messages.
|
|
2043
|
+
*
|
|
2044
|
+
* This utility helps implement a better UX for async validations by:
|
|
2045
|
+
* 1. Delaying the display of "Validating..." messages until validation takes longer than `showAfter`ms (default: 200ms)
|
|
2046
|
+
* 2. Keeping the message visible for at least `minimumDisplay`ms (default: 500ms) once shown to prevent flickering
|
|
2047
|
+
*
|
|
2048
|
+
* @example
|
|
2049
|
+
* ```typescript
|
|
2050
|
+
* @Component({
|
|
2051
|
+
* template: `
|
|
2052
|
+
* @if (showPendingMessage()) {
|
|
2053
|
+
* <div role="status" aria-live="polite">Validating…</div>
|
|
2054
|
+
* }
|
|
2055
|
+
* `
|
|
2056
|
+
* })
|
|
2057
|
+
* export class CustomControlWrapperComponent {
|
|
2058
|
+
* protected readonly errorDisplay = inject(FormErrorDisplayDirective, { self: true });
|
|
2059
|
+
*
|
|
2060
|
+
* // Create debounced pending state
|
|
2061
|
+
* private readonly pendingState = createDebouncedPendingState(
|
|
2062
|
+
* this.errorDisplay.isPending,
|
|
2063
|
+
* { showAfter: 200, minimumDisplay: 500 }
|
|
2064
|
+
* );
|
|
2065
|
+
*
|
|
2066
|
+
* protected readonly showPendingMessage = this.pendingState.showPendingMessage;
|
|
2067
|
+
* }
|
|
2068
|
+
* ```
|
|
2069
|
+
*
|
|
2070
|
+
* @param isPending - Signal indicating whether async validation is currently pending
|
|
2071
|
+
* @param options - Configuration options for debouncing behavior
|
|
2072
|
+
* @returns Object containing the debounced showPendingMessage signal and cleanup function
|
|
2073
|
+
*/
|
|
2074
|
+
function createDebouncedPendingState(isPending, options = {}) {
|
|
2075
|
+
const { showAfter = 200, minimumDisplay = 500 } = options;
|
|
2076
|
+
// Create writable signal for debounced state
|
|
2077
|
+
const showPendingMessageSignal = signal(false, ...(ngDevMode ? [{ debugName: "showPendingMessageSignal" }] : []));
|
|
2078
|
+
// Track timeouts
|
|
2079
|
+
let pendingTimeout = null;
|
|
2080
|
+
let minimumDisplayTimeout = null;
|
|
2081
|
+
// Cleanup function
|
|
2082
|
+
const cleanup = () => {
|
|
2083
|
+
if (pendingTimeout) {
|
|
2084
|
+
clearTimeout(pendingTimeout);
|
|
2085
|
+
pendingTimeout = null;
|
|
2086
|
+
}
|
|
2087
|
+
if (minimumDisplayTimeout) {
|
|
2088
|
+
clearTimeout(minimumDisplayTimeout);
|
|
2089
|
+
minimumDisplayTimeout = null;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
// Effect to manage debounced pending message display
|
|
2093
|
+
effect((onCleanup) => {
|
|
2094
|
+
const pending = isPending();
|
|
2095
|
+
if (pending) {
|
|
2096
|
+
// Clear any existing minimum display timeout
|
|
2097
|
+
if (minimumDisplayTimeout) {
|
|
2098
|
+
clearTimeout(minimumDisplayTimeout);
|
|
2099
|
+
minimumDisplayTimeout = null;
|
|
2100
|
+
}
|
|
2101
|
+
// Start delay timer before showing pending message
|
|
2102
|
+
pendingTimeout = setTimeout(() => {
|
|
2103
|
+
showPendingMessageSignal.set(true);
|
|
2104
|
+
pendingTimeout = null;
|
|
2105
|
+
}, showAfter);
|
|
2106
|
+
onCleanup(() => {
|
|
2107
|
+
if (pendingTimeout) {
|
|
2108
|
+
clearTimeout(pendingTimeout);
|
|
2109
|
+
pendingTimeout = null;
|
|
1963
2110
|
}
|
|
1964
2111
|
});
|
|
1965
2112
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
this.ngForm.form.markAllAsTouched();
|
|
1973
|
-
});
|
|
1974
|
-
/**
|
|
1975
|
-
* Single bidirectional synchronization effect using linkedSignal.
|
|
1976
|
-
* Uses proper deep comparison and change tracking for correct sync direction.
|
|
1977
|
-
* Note: formValue is read-only input(), so we emit changes via formValueChange output.
|
|
1978
|
-
*/
|
|
1979
|
-
effect(() => {
|
|
1980
|
-
const formValue = this.#formValueSignal();
|
|
1981
|
-
const modelValue = this.formValue();
|
|
1982
|
-
// Skip if either is null
|
|
1983
|
-
if (!formValue && !modelValue)
|
|
1984
|
-
return;
|
|
1985
|
-
// Compute change flags first
|
|
1986
|
-
const formChanged = !fastDeepEqual(formValue, this.#lastSyncedFormValue);
|
|
1987
|
-
const modelChanged = !fastDeepEqual(modelValue, this.#lastSyncedModelValue);
|
|
1988
|
-
// Early return if nothing changed
|
|
1989
|
-
if (!formChanged && !modelChanged) {
|
|
1990
|
-
return;
|
|
1991
|
-
}
|
|
1992
|
-
if (formChanged && !modelChanged) {
|
|
1993
|
-
// Form was modified by user -> form wins
|
|
1994
|
-
// Note: We can't call this.formValue.set() since it's an input()
|
|
1995
|
-
// The formValueChange output will emit the new value
|
|
1996
|
-
// Use untracked() to avoid infinite loops - we're updating tracking state here
|
|
1997
|
-
untracked(() => {
|
|
1998
|
-
this.#lastSyncedFormValue = formValue;
|
|
1999
|
-
this.#lastSyncedModelValue = formValue;
|
|
2000
|
-
});
|
|
2113
|
+
else {
|
|
2114
|
+
// Validation completed
|
|
2115
|
+
if (pendingTimeout) {
|
|
2116
|
+
// Validation completed before delay - don't show message
|
|
2117
|
+
clearTimeout(pendingTimeout);
|
|
2118
|
+
pendingTimeout = null;
|
|
2001
2119
|
}
|
|
2002
|
-
else if (
|
|
2003
|
-
//
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
this.ngForm.form.patchValue(modelValue, { emitEvent: false });
|
|
2120
|
+
else if (showPendingMessageSignal()) {
|
|
2121
|
+
// Message was shown - keep it visible for minimum duration
|
|
2122
|
+
minimumDisplayTimeout = setTimeout(() => {
|
|
2123
|
+
showPendingMessageSignal.set(false);
|
|
2124
|
+
minimumDisplayTimeout = null;
|
|
2125
|
+
}, minimumDisplay);
|
|
2126
|
+
onCleanup(() => {
|
|
2127
|
+
if (minimumDisplayTimeout) {
|
|
2128
|
+
clearTimeout(minimumDisplayTimeout);
|
|
2129
|
+
minimumDisplayTimeout = null;
|
|
2013
2130
|
}
|
|
2014
|
-
this.#lastSyncedFormValue = modelValue;
|
|
2015
|
-
this.#lastSyncedModelValue = modelValue;
|
|
2016
2131
|
});
|
|
2017
2132
|
}
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
return {
|
|
2136
|
+
showPendingMessage: showPendingMessageSignal.asReadonly(),
|
|
2137
|
+
cleanup,
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// Counter for unique IDs
|
|
2142
|
+
let nextUniqueId$2 = 0;
|
|
2143
|
+
/**
|
|
2144
|
+
* Accessible form control wrapper with WCAG 2.2 AA compliance.
|
|
2145
|
+
*
|
|
2146
|
+
* Wrap form fields to automatically display validation errors, warnings, and pending states
|
|
2147
|
+
* with proper accessibility attributes.
|
|
2148
|
+
*
|
|
2149
|
+
* @usageNotes
|
|
2150
|
+
*
|
|
2151
|
+
* ### Basic Usage
|
|
2152
|
+
* ```html
|
|
2153
|
+
* <ngx-control-wrapper>
|
|
2154
|
+
* <label for="email">Email</label>
|
|
2155
|
+
* <input id="email" name="email" [ngModel]="formValue().email" />
|
|
2156
|
+
* </ngx-control-wrapper>
|
|
2157
|
+
* ```
|
|
2158
|
+
*
|
|
2159
|
+
* ### Error Display Modes
|
|
2160
|
+
* Control when errors appear using the `errorDisplayMode` input:
|
|
2161
|
+
* - `'on-blur-or-submit'` (default): Show errors after blur OR form submit
|
|
2162
|
+
* - `'on-blur'`: Show errors only after blur
|
|
2163
|
+
* - `'on-submit'`: Show errors only after form submit
|
|
2164
|
+
*
|
|
2165
|
+
* ```html
|
|
2166
|
+
* <ngx-control-wrapper [errorDisplayMode]="'on-submit'">
|
|
2167
|
+
* <input name="email" [ngModel]="formValue().email" />
|
|
2168
|
+
* </ngx-control-wrapper>
|
|
2169
|
+
* ```
|
|
2170
|
+
*
|
|
2171
|
+
* ### Accessibility Features (Automatic)
|
|
2172
|
+
* - Unique IDs for error/warning/pending regions
|
|
2173
|
+
* - `aria-describedby` linking errors to form controls
|
|
2174
|
+
* - `aria-invalid="true"` when errors are shown
|
|
2175
|
+
* - Uses `role="status"` with `aria-live="polite"` for non-disruptive announcements
|
|
2176
|
+
* - Debounced pending state to prevent flashing for quick validations
|
|
2177
|
+
*
|
|
2178
|
+
* ### WCAG 2.2 AA - Error Severity Levels
|
|
2179
|
+
* This component uses `role="status"` for **field-level** validation messages:
|
|
2180
|
+
* - **Errors**: Non-disruptive announcement (user can continue filling other fields)
|
|
2181
|
+
* - **Warnings**: Informational only, doesn't block submission
|
|
2182
|
+
* - **Pending**: Status update while async validation runs
|
|
2183
|
+
*
|
|
2184
|
+
* For **form-level blocking errors** (e.g., submission failed), implement a separate
|
|
2185
|
+
* error summary component with `role="alert"` and `aria-live="assertive"`.
|
|
2186
|
+
*
|
|
2187
|
+
* @see {@link FormErrorDisplayDirective} for custom wrapper implementation
|
|
2188
|
+
*
|
|
2189
|
+
* Form-Level Blocking Errors:
|
|
2190
|
+
* - For post-submit validation errors that block submission, implement a separate
|
|
2191
|
+
* form-level error summary with role="alert" and aria-live="assertive"
|
|
2192
|
+
* - This component uses role="status" for field-level errors (non-disruptive)
|
|
2193
|
+
* - Example for form-level errors:
|
|
2194
|
+
* ```html
|
|
2195
|
+
* <!-- Keep in DOM; update text content on submit -->
|
|
2196
|
+
* <div id="form-errors" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
|
2197
|
+
* ```
|
|
2198
|
+
* - This separation provides immediate announcements for blocking form errors while
|
|
2199
|
+
* keeping inline field errors non-disruptive. Follows WCAG ARIA21/ARIA22 guidance.
|
|
2200
|
+
*
|
|
2201
|
+
* Error & Warning Display Behavior:
|
|
2202
|
+
* - The error display mode can be configured globally using the NGX_ERROR_DISPLAY_MODE_TOKEN injection token, or per instance using the `errorDisplayMode` input on FormErrorDisplayDirective (which this component uses as a hostDirective).
|
|
2203
|
+
* - Possible values: 'on-blur' | 'on-submit' | 'on-blur-or-submit' (default: 'on-blur-or-submit')
|
|
2204
|
+
* - The warning display mode can be configured globally using NGX_WARNING_DISPLAY_MODE_TOKEN, or per instance using the `warningDisplayMode` input on FormErrorDisplayDirective.
|
|
2205
|
+
* - Possible values: 'on-touch' | 'on-validated-or-touch' (default: 'on-validated-or-touch')
|
|
2206
|
+
*
|
|
2207
|
+
* Example (per instance):
|
|
2208
|
+
* <div ngxControlWrapper>
|
|
2209
|
+
* <label>
|
|
2210
|
+
* <span>First name</span>
|
|
2211
|
+
* <input type="text" name="firstName" [ngModel]="formValue().firstName" />
|
|
2212
|
+
* </label>
|
|
2213
|
+
* </div>
|
|
2214
|
+
* /// To customize errorDisplayMode for this instance, use the errorDisplayMode input.
|
|
2215
|
+
*
|
|
2216
|
+
* Example (with warnings and pending):
|
|
2217
|
+
* <ngx-control-wrapper>
|
|
2218
|
+
* <input name="username" ngModel />
|
|
2219
|
+
* </ngx-control-wrapper>
|
|
2220
|
+
* /// If async validation is running for >200ms, a spinner and 'Validating…' will be shown.
|
|
2221
|
+
* /// Once shown, the validation message stays visible for minimum 500ms to prevent flashing.
|
|
2222
|
+
* /// If Vest warnings are present, they will be shown below errors.
|
|
2223
|
+
*
|
|
2224
|
+
* Example (global config):
|
|
2225
|
+
* import { provide } from '@angular/core';
|
|
2226
|
+
* import { NGX_ERROR_DISPLAY_MODE_TOKEN } from 'ngx-vest-forms';
|
|
2227
|
+
* @Component({
|
|
2228
|
+
* providers: [
|
|
2229
|
+
* provide(NGX_ERROR_DISPLAY_MODE_TOKEN, { useValue: 'submit' })
|
|
2230
|
+
* ]
|
|
2231
|
+
* })
|
|
2232
|
+
* export class MyComponent {}
|
|
2233
|
+
*
|
|
2234
|
+
* Best Practices:
|
|
2235
|
+
* - Use for every input or group in your forms.
|
|
2236
|
+
* - Do not manually display errors for individual fields; rely on this wrapper.
|
|
2237
|
+
* - Validate with tools like Accessibility Insights and real screen reader testing.
|
|
2238
|
+
*
|
|
2239
|
+
* @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA19 - ARIA19: Using ARIA role=alert
|
|
2240
|
+
* @see https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 - ARIA22: Using role=status
|
|
2241
|
+
*/
|
|
2242
|
+
class ControlWrapperComponent {
|
|
2243
|
+
mergeAriaDescribedBy(existing, wrapperActiveIds) {
|
|
2244
|
+
const existingTokens = (existing ?? '')
|
|
2245
|
+
.split(/\s+/)
|
|
2246
|
+
.map((t) => t.trim())
|
|
2247
|
+
.filter(Boolean);
|
|
2248
|
+
// Remove any previous wrapper-owned IDs from the existing list.
|
|
2249
|
+
const existingWithoutWrapper = existingTokens.filter((t) => !this.wrapperOwnedDescribedByIds.includes(t));
|
|
2250
|
+
// Append current wrapper IDs, preserving existing order and uniqueness.
|
|
2251
|
+
const merged = [...existingWithoutWrapper];
|
|
2252
|
+
for (const id of wrapperActiveIds) {
|
|
2253
|
+
if (!merged.includes(id)) {
|
|
2254
|
+
merged.push(id);
|
|
2112
2255
|
}
|
|
2113
2256
|
}
|
|
2114
|
-
|
|
2115
|
-
// Update all form controls validity which will trigger all form events
|
|
2116
|
-
this.ngForm.form.updateValueAndValidity({ emitEvent: true });
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
/**
|
|
2120
|
-
* Convenience method to mark all form controls as touched.
|
|
2121
|
-
*
|
|
2122
|
-
* This is useful for showing all validation errors at once, typically when
|
|
2123
|
-
* the user clicks a submit button. When a field is marked as touched,
|
|
2124
|
-
* the error display logic (based on `errorDisplayMode`) will show its errors.
|
|
2125
|
-
*
|
|
2126
|
-
* **Note on automatic behavior:**
|
|
2127
|
-
* When using the default error display mode (`on-blur-or-submit`), you typically
|
|
2128
|
-
* don't need to call this method manually for regular form submissions. The form
|
|
2129
|
-
* directive automatically marks all fields as touched on `ngSubmit`, so errors
|
|
2130
|
-
* will display automatically when the user submits the form.
|
|
2131
|
-
*
|
|
2132
|
-
* **When to use this method:**
|
|
2133
|
-
* - Multiple forms with a single submit button (forms without their own submit)
|
|
2134
|
-
* - Programmatic form submission without triggering `ngSubmit`
|
|
2135
|
-
* - Custom validation flows outside the normal submit process
|
|
2136
|
-
*
|
|
2137
|
-
* **Note:** This method only marks fields as touched—it does NOT re-run validation.
|
|
2138
|
-
* If you also need to re-run validation (e.g., after structure changes), call
|
|
2139
|
-
* `triggerFormValidation()` as well.
|
|
2140
|
-
*
|
|
2141
|
-
* @example
|
|
2142
|
-
* ```typescript
|
|
2143
|
-
* /// Standard form submission - NO need to call markAllAsTouched()
|
|
2144
|
-
* /// The directive handles this automatically on ngSubmit
|
|
2145
|
-
* <form ngxVestForm (ngSubmit)="save()">
|
|
2146
|
-
* <button type="submit">Submit</button>
|
|
2147
|
-
* </form>
|
|
2148
|
-
*
|
|
2149
|
-
* /// Multiple forms with one submit button
|
|
2150
|
-
* submitAll() {
|
|
2151
|
-
* this.form1().markAllAsTouched();
|
|
2152
|
-
* this.form2().markAllAsTouched();
|
|
2153
|
-
* if (this.form1().formState().valid && this.form2().formState().valid) {
|
|
2154
|
-
* /// Submit all forms
|
|
2155
|
-
* }
|
|
2156
|
-
* }
|
|
2157
|
-
* ```
|
|
2158
|
-
*/
|
|
2159
|
-
markAllAsTouched() {
|
|
2160
|
-
this.ngForm.form.markAllAsTouched();
|
|
2161
|
-
}
|
|
2162
|
-
/**
|
|
2163
|
-
* Resets the form to a pristine, untouched state with optional new values.
|
|
2164
|
-
*
|
|
2165
|
-
* This method properly resets the form by:
|
|
2166
|
-
* 1. Resetting Angular's underlying NgForm with the provided value
|
|
2167
|
-
* 2. Clearing the bidirectional sync tracking state
|
|
2168
|
-
* 3. Forcing a form validity update to clear any stale validation errors
|
|
2169
|
-
*
|
|
2170
|
-
* **Why this method exists:**
|
|
2171
|
-
* When using the pattern `formValue.set({})` to reset a form, there can be a timing
|
|
2172
|
-
* issue where the form controls in the DOM still hold their old values while the
|
|
2173
|
-
* signal has already been updated. This creates a conflict in the bidirectional
|
|
2174
|
-
* sync logic, requiring workarounds like calling `formValue.set({})` twice with
|
|
2175
|
-
* a setTimeout. This method provides a proper solution by:
|
|
2176
|
-
* - Calling Angular's `NgForm.resetForm()` which properly clears all controls
|
|
2177
|
-
* - Clearing the internal sync tracking state to avoid stale comparisons
|
|
2178
|
-
* - Triggering a form validity update to ensure validation state is current
|
|
2179
|
-
*
|
|
2180
|
-
* **Usage:**
|
|
2181
|
-
* Instead of the double-set workaround:
|
|
2182
|
-
* ```typescript
|
|
2183
|
-
* // ❌ Old workaround (avoid)
|
|
2184
|
-
* reset(): void {
|
|
2185
|
-
* this.formValue.set({});
|
|
2186
|
-
* setTimeout(() => this.formValue.set({}), 0);
|
|
2187
|
-
* }
|
|
2188
|
-
*
|
|
2189
|
-
* // ✅ Preferred approach
|
|
2190
|
-
* vestForm = viewChild.required('vestForm', { read: FormDirective });
|
|
2191
|
-
* reset(): void {
|
|
2192
|
-
* this.formValue.set({});
|
|
2193
|
-
* this.vestForm().resetForm();
|
|
2194
|
-
* }
|
|
2195
|
-
* ```
|
|
2196
|
-
*
|
|
2197
|
-
* **With new values:**
|
|
2198
|
-
* ```typescript
|
|
2199
|
-
* // Reset and set new initial values
|
|
2200
|
-
* resetWithDefaults(): void {
|
|
2201
|
-
* const defaults = { firstName: '', lastName: '', age: 18 };
|
|
2202
|
-
* this.formValue.set(defaults);
|
|
2203
|
-
* this.vestForm().resetForm(defaults);
|
|
2204
|
-
* }
|
|
2205
|
-
* ```
|
|
2206
|
-
*
|
|
2207
|
-
* @param value - Optional new value to reset the form to. If not provided,
|
|
2208
|
-
* resets to empty/default values.
|
|
2209
|
-
*
|
|
2210
|
-
* @see {@link markAllAsTouched} for showing validation errors
|
|
2211
|
-
* @see {@link triggerFormValidation} for re-running validation without reset
|
|
2212
|
-
*/
|
|
2213
|
-
resetForm(value) {
|
|
2214
|
-
// Reset Angular's form to clear all controls and mark as pristine/untouched
|
|
2215
|
-
this.ngForm.resetForm(value ?? undefined);
|
|
2216
|
-
// Clear the bidirectional sync tracking state so the next formValue change
|
|
2217
|
-
// is treated as a model change (not a conflict with stale form values)
|
|
2218
|
-
this.#lastSyncedFormValue = null;
|
|
2219
|
-
this.#lastSyncedModelValue = null;
|
|
2220
|
-
this.#lastLinkedValue = null;
|
|
2221
|
-
// Force change detection to ensure DOM updates are reflected
|
|
2222
|
-
// Note: This is still needed even with signals because we're modifying NgForm
|
|
2223
|
-
// (reactive forms), not signals. The formValue signal updates happen in the
|
|
2224
|
-
// consumer component. detectChanges() ensures NgForm's reset is reflected in
|
|
2225
|
-
// the DOM before we update validity.
|
|
2226
|
-
this.cdr.detectChanges();
|
|
2227
|
-
// Trigger validation update to clear any stale errors
|
|
2228
|
-
// Now synchronous since detectChanges() has flushed DOM updates
|
|
2229
|
-
this.ngForm.form.updateValueAndValidity({ emitEvent: true });
|
|
2257
|
+
return merged.length > 0 ? merged.join(' ') : null;
|
|
2230
2258
|
}
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2259
|
+
constructor() {
|
|
2260
|
+
this.errorDisplay = inject(FormErrorDisplayDirective, {
|
|
2261
|
+
self: true,
|
|
2262
|
+
});
|
|
2263
|
+
this.elementRef = inject(ElementRef);
|
|
2264
|
+
this.destroyRef = inject(DestroyRef);
|
|
2265
|
+
/**
|
|
2266
|
+
* Controls how this wrapper applies ARIA attributes to descendant controls.
|
|
2267
|
+
*
|
|
2268
|
+
* - `all-controls` (default, backwards compatible): apply `aria-describedby` / `aria-invalid`
|
|
2269
|
+
* to all `input/select/textarea` elements inside the wrapper.
|
|
2270
|
+
* - `single-control`: apply ARIA attributes only when exactly one control is found.
|
|
2271
|
+
* (Useful for wrappers that sometimes contain helper buttons/controls.)
|
|
2272
|
+
* - `none`: do not mutate descendant controls at all (group-safe mode).
|
|
2273
|
+
*
|
|
2274
|
+
* Notes:
|
|
2275
|
+
* - Use `none` when wrapping a container (e.g. `NgModelGroup`) to avoid stamping ARIA
|
|
2276
|
+
* across multiple child controls.
|
|
2277
|
+
* - This does not affect whether messages render; it only affects ARIA wiring.
|
|
2278
|
+
*/
|
|
2279
|
+
this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
|
|
2280
|
+
// Generate unique IDs for ARIA associations
|
|
2281
|
+
this.uniqueId = `ngx-control-wrapper-${nextUniqueId$2++}`;
|
|
2282
|
+
this.errorId = `${this.uniqueId}-error`;
|
|
2283
|
+
this.warningId = `${this.uniqueId}-warning`;
|
|
2284
|
+
this.pendingId = `${this.uniqueId}-pending`;
|
|
2285
|
+
// Track form controls found in the wrapper
|
|
2286
|
+
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
|
|
2287
|
+
// Signals when content is initialized so effects can safely touch the DOM.
|
|
2288
|
+
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
|
|
2289
|
+
// MutationObserver to detect dynamically added/removed controls
|
|
2290
|
+
this.mutationObserver = null;
|
|
2291
|
+
/**
|
|
2292
|
+
* Debounced pending state to prevent flashing for quick async validations.
|
|
2293
|
+
* Uses createDebouncedPendingState utility with 500ms delay and 500ms minimum display.
|
|
2294
|
+
*/
|
|
2295
|
+
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
|
|
2296
|
+
this.showPendingMessage = this.pendingState.showPendingMessage;
|
|
2297
|
+
/**
|
|
2298
|
+
* Whether to display warnings.
|
|
2299
|
+
* Warnings are shown when:
|
|
2300
|
+
* 1. The field has been touched (user has interacted with it)
|
|
2301
|
+
* 2. The field has warnings to display
|
|
2302
|
+
* 3. The field is not currently pending validation
|
|
2303
|
+
*
|
|
2304
|
+
* NOTE: Unlike errors, warnings can exist on VALID fields (warnings-only scenario).
|
|
2305
|
+
* We don't require isInvalid() because Vest warn() tests don't affect field validity.
|
|
2306
|
+
*
|
|
2307
|
+
* UX Note: We include `hasBeenValidated` here to support cross-field validation.
|
|
2308
|
+
* If Field A triggers validation on Field B (via validationConfig), Field B should
|
|
2309
|
+
* show warnings if it has them, even if the user hasn't touched Field B yet.
|
|
2310
|
+
* Unlike errors (which block submission), warnings are informational and safe to safe to show.
|
|
2311
|
+
*/
|
|
2312
|
+
this.shouldShowWarnings = computed(() => {
|
|
2313
|
+
const isTouched = this.errorDisplay.isTouched();
|
|
2314
|
+
const hasBeenValidated = this.errorDisplay.hasBeenValidated();
|
|
2315
|
+
const isPending = this.errorDisplay.isPending();
|
|
2316
|
+
const hasWarnings = this.errorDisplay.warnings().length > 0;
|
|
2317
|
+
const mode = this.errorDisplay.warningDisplayMode();
|
|
2318
|
+
const shouldShowAfterInteraction = mode === 'on-touch' ? isTouched : isTouched || hasBeenValidated;
|
|
2319
|
+
return shouldShowAfterInteraction && hasWarnings && !isPending;
|
|
2320
|
+
}, ...(ngDevMode ? [{ debugName: "shouldShowWarnings" }] : []));
|
|
2321
|
+
/**
|
|
2322
|
+
* Computed signal that builds aria-describedby string based on visible regions
|
|
2323
|
+
*/
|
|
2324
|
+
this.ariaDescribedBy = computed(() => {
|
|
2325
|
+
const ids = [];
|
|
2326
|
+
if (this.errorDisplay.shouldShowErrors()) {
|
|
2327
|
+
ids.push(this.errorId);
|
|
2328
|
+
}
|
|
2329
|
+
if (this.shouldShowWarnings()) {
|
|
2330
|
+
ids.push(this.warningId);
|
|
2331
|
+
}
|
|
2332
|
+
if (this.showPendingMessage()) {
|
|
2333
|
+
ids.push(this.pendingId);
|
|
2334
|
+
}
|
|
2335
|
+
return ids.length > 0 ? ids.join(' ') : null;
|
|
2336
|
+
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
|
|
2337
|
+
/**
|
|
2338
|
+
* IDs managed by this wrapper when composing aria-describedby.
|
|
2339
|
+
*
|
|
2340
|
+
* We remove only these from the consumer-provided aria-describedby tokens and then
|
|
2341
|
+
* append the currently-relevant wrapper IDs. This prevents clobbering app-provided
|
|
2342
|
+
* hint/help text associations.
|
|
2343
|
+
*/
|
|
2344
|
+
this.wrapperOwnedDescribedByIds = [
|
|
2345
|
+
this.errorId,
|
|
2346
|
+
this.warningId,
|
|
2347
|
+
this.pendingId,
|
|
2348
|
+
];
|
|
2349
|
+
// Effect to update aria-describedby and aria-invalid on form controls
|
|
2350
|
+
effect(() => {
|
|
2351
|
+
if (!this.contentInitialized())
|
|
2352
|
+
return;
|
|
2353
|
+
const mode = this.ariaAssociationMode();
|
|
2354
|
+
if (mode === 'none') {
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
const describedBy = this.ariaDescribedBy();
|
|
2358
|
+
const wrapperActiveIds = describedBy
|
|
2359
|
+
? describedBy.split(/\s+/).filter(Boolean)
|
|
2360
|
+
: [];
|
|
2361
|
+
const shouldShowErrors = this.errorDisplay.shouldShowErrors();
|
|
2362
|
+
const targets = (() => {
|
|
2363
|
+
const controls = this.formControls();
|
|
2364
|
+
if (mode === 'single-control') {
|
|
2365
|
+
return controls.length === 1 ? controls : [];
|
|
2290
2366
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2367
|
+
return controls;
|
|
2368
|
+
})();
|
|
2369
|
+
targets.forEach((control) => {
|
|
2370
|
+
// Update aria-describedby (merge, don't overwrite)
|
|
2371
|
+
const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), wrapperActiveIds);
|
|
2372
|
+
if (nextDescribedBy) {
|
|
2373
|
+
control.setAttribute('aria-describedby', nextDescribedBy);
|
|
2294
2374
|
}
|
|
2295
|
-
|
|
2296
|
-
|
|
2375
|
+
else {
|
|
2376
|
+
control.removeAttribute('aria-describedby');
|
|
2377
|
+
}
|
|
2378
|
+
// Update aria-invalid
|
|
2379
|
+
if (shouldShowErrors) {
|
|
2380
|
+
control.setAttribute('aria-invalid', 'true');
|
|
2381
|
+
}
|
|
2382
|
+
else {
|
|
2383
|
+
control.removeAttribute('aria-invalid');
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
});
|
|
2387
|
+
// Clean up MutationObserver when component is destroyed
|
|
2388
|
+
this.destroyRef.onDestroy(() => {
|
|
2389
|
+
this.mutationObserver?.disconnect();
|
|
2390
|
+
this.mutationObserver = null;
|
|
2391
|
+
});
|
|
2392
|
+
// Effect to enable/disable DOM observation based on ariaAssociationMode.
|
|
2393
|
+
// This keeps the wrapper cheap in group-safe mode.
|
|
2394
|
+
effect(() => {
|
|
2395
|
+
if (!this.contentInitialized())
|
|
2396
|
+
return;
|
|
2397
|
+
const mode = this.ariaAssociationMode();
|
|
2398
|
+
if (mode === 'none') {
|
|
2399
|
+
this.mutationObserver?.disconnect();
|
|
2400
|
+
this.mutationObserver = null;
|
|
2401
|
+
if (this.formControls().length > 0) {
|
|
2402
|
+
this.formControls.set([]);
|
|
2403
|
+
}
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
// Ensure controls list is up to date.
|
|
2407
|
+
this.updateFormControls();
|
|
2408
|
+
// Ensure MutationObserver is installed (dynamic @if/@for support).
|
|
2409
|
+
if (!this.mutationObserver) {
|
|
2410
|
+
this.mutationObserver = new MutationObserver(() => {
|
|
2411
|
+
this.updateFormControls();
|
|
2412
|
+
});
|
|
2413
|
+
this.mutationObserver.observe(this.elementRef.nativeElement, {
|
|
2414
|
+
childList: true,
|
|
2415
|
+
subtree: true,
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2297
2419
|
}
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
#setupValidationConfig() {
|
|
2303
|
-
const form = this.ngForm.form;
|
|
2304
|
-
toObservable(this.validationConfig)
|
|
2305
|
-
.pipe(distinctUntilChanged(), switchMap((config) => {
|
|
2306
|
-
// Cast to the expected type for the helper method
|
|
2307
|
-
const typedConfig = config;
|
|
2308
|
-
return this.#createValidationStreams(form, typedConfig);
|
|
2309
|
-
}), takeUntilDestroyed(this.destroyRef))
|
|
2310
|
-
.subscribe();
|
|
2420
|
+
ngAfterContentInit() {
|
|
2421
|
+
this.contentInitialized.set(true);
|
|
2422
|
+
// ARIA wiring + observer setup is managed by effects so that the wrapper can
|
|
2423
|
+
// opt out (ariaAssociationMode="none").
|
|
2311
2424
|
}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
*
|
|
2316
|
-
* @param form - The NgForm instance
|
|
2317
|
-
* @param config - The validation configuration mapping trigger fields to dependent fields
|
|
2318
|
-
* @returns Observable that emits when any trigger field changes and dependent fields need validation
|
|
2319
|
-
*/
|
|
2320
|
-
#createValidationStreams(form, config) {
|
|
2321
|
-
if (!config) {
|
|
2322
|
-
this.validationInProgress.clear();
|
|
2323
|
-
return EMPTY;
|
|
2324
|
-
}
|
|
2325
|
-
const streams = Object.keys(config).map((triggerField) => {
|
|
2326
|
-
const dependents = config[triggerField] || [];
|
|
2327
|
-
return this.#createTriggerStream(form, triggerField, dependents);
|
|
2328
|
-
});
|
|
2329
|
-
return streams.length > 0 ? merge(...streams) : EMPTY;
|
|
2425
|
+
ngOnDestroy() {
|
|
2426
|
+
this.mutationObserver?.disconnect();
|
|
2427
|
+
this.mutationObserver = null;
|
|
2330
2428
|
}
|
|
2331
2429
|
/**
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2334
|
-
* This method handles:
|
|
2335
|
-
* 1. Waiting for the trigger control to exist in the form (for @if scenarios)
|
|
2336
|
-
* 2. Listening to value changes with debouncing
|
|
2337
|
-
* 3. Waiting for form to be idle before triggering dependents
|
|
2338
|
-
* 4. Waiting for all dependent controls to exist
|
|
2339
|
-
* 5. Updating dependent field validity with loop prevention
|
|
2340
|
-
* 6. Touch state propagation from trigger to dependents
|
|
2341
|
-
*
|
|
2342
|
-
* @param form - The NgForm instance
|
|
2343
|
-
* @param triggerField - Field path that triggers validation (e.g., 'password')
|
|
2344
|
-
* @param dependents - Array of dependent field paths to revalidate (e.g., ['confirmPassword'])
|
|
2345
|
-
* @returns Observable that completes after dependent fields are validated
|
|
2430
|
+
* Query and update the list of form controls within this wrapper.
|
|
2431
|
+
* Called on init and whenever the DOM structure changes.
|
|
2346
2432
|
*/
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
// CRITICAL: take(1) to stop listening after control is found
|
|
2351
|
-
// Without this, the pipeline continues to listen to statusChanges,
|
|
2352
|
-
// creating a feedback loop where validation triggers re-trigger the pipeline
|
|
2353
|
-
take(1));
|
|
2354
|
-
return triggerControl$.pipe(switchMap((control) => {
|
|
2355
|
-
return control.valueChanges.pipe(
|
|
2356
|
-
// CRITICAL: Filter out changes when this field is being validated by another field's config
|
|
2357
|
-
// This prevents circular triggers in bidirectional validationConfig
|
|
2358
|
-
filter(() => !this.validationInProgress.has(triggerField)), debounceTime(this.configDebounceTime), switchMap(() => {
|
|
2359
|
-
return this.#waitForFormIdle(form, control);
|
|
2360
|
-
}), switchMap(() => this.#waitForDependentControls(form, dependents, control)), tap(() => this.#updateDependentFields(form, control, triggerField, dependents)));
|
|
2361
|
-
}));
|
|
2433
|
+
updateFormControls() {
|
|
2434
|
+
const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
|
|
2435
|
+
this.formControls.set(Array.from(controls));
|
|
2362
2436
|
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2437
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ControlWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2438
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.1", 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 }); }
|
|
2439
|
+
}
|
|
2440
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ControlWrapperComponent, decorators: [{
|
|
2441
|
+
type: Component,
|
|
2442
|
+
args: [{ selector: 'ngx-control-wrapper, sc-control-wrapper, [scControlWrapper], [ngxControlWrapper], [ngx-control-wrapper], [sc-control-wrapper]', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
2443
|
+
class: 'ngx-control-wrapper sc-control-wrapper',
|
|
2444
|
+
'[class.ngx-control-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
|
|
2445
|
+
'[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
|
|
2446
|
+
}, hostDirectives: [
|
|
2447
|
+
{
|
|
2448
|
+
directive: FormErrorDisplayDirective,
|
|
2449
|
+
inputs: ['errorDisplayMode', 'warningDisplayMode'],
|
|
2450
|
+
},
|
|
2451
|
+
], 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"] }]
|
|
2452
|
+
}], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
|
|
2453
|
+
|
|
2454
|
+
let nextUniqueId$1 = 0;
|
|
2455
|
+
/**
|
|
2456
|
+
* Group-safe wrapper for `NgModelGroup` containers.
|
|
2457
|
+
*
|
|
2458
|
+
* This component renders group-level errors/warnings/pending UI, but intentionally
|
|
2459
|
+
* does **not** stamp `aria-describedby` / `aria-invalid` onto descendant controls.
|
|
2460
|
+
*
|
|
2461
|
+
* Use this when you want a wrapper around a container that has multiple inputs.
|
|
2462
|
+
* For single inputs, prefer `<ngx-control-wrapper>`.
|
|
2463
|
+
*/
|
|
2464
|
+
class FormGroupWrapperComponent {
|
|
2465
|
+
constructor() {
|
|
2466
|
+
this.errorDisplay = inject(FormErrorDisplayDirective, {
|
|
2467
|
+
self: true,
|
|
2468
|
+
});
|
|
2469
|
+
/**
|
|
2470
|
+
* Controls the debounce behavior for the pending message.
|
|
2471
|
+
* Defaults are conservative to avoid flashing.
|
|
2472
|
+
*/
|
|
2473
|
+
this.pendingDebounce = input({
|
|
2474
|
+
showAfter: 500,
|
|
2475
|
+
minimumDisplay: 500,
|
|
2476
|
+
}, ...(ngDevMode ? [{ debugName: "pendingDebounce" }] : []));
|
|
2477
|
+
this.uniqueId = `ngx-form-group-wrapper-${nextUniqueId$1++}`;
|
|
2478
|
+
this.errorId = `${this.uniqueId}-error`;
|
|
2479
|
+
this.warningId = `${this.uniqueId}-warning`;
|
|
2480
|
+
this.pendingId = `${this.uniqueId}-pending`;
|
|
2481
|
+
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, this.pendingDebounce());
|
|
2482
|
+
this.showPendingMessage = this.pendingState.showPendingMessage;
|
|
2483
|
+
/**
|
|
2484
|
+
* Helpful if consumers want to wire aria-describedby manually (e.g. fieldset/legend pattern).
|
|
2485
|
+
*/
|
|
2486
|
+
this.describedByIds = computed(() => {
|
|
2487
|
+
const ids = [];
|
|
2488
|
+
if (this.errorDisplay.shouldShowErrors()) {
|
|
2489
|
+
ids.push(this.errorId);
|
|
2490
|
+
}
|
|
2491
|
+
if (this.errorDisplay.warnings().length > 0) {
|
|
2492
|
+
ids.push(this.warningId);
|
|
2493
|
+
}
|
|
2494
|
+
if (this.showPendingMessage()) {
|
|
2495
|
+
ids.push(this.pendingId);
|
|
2496
|
+
}
|
|
2497
|
+
return ids.length > 0 ? ids.join(' ') : null;
|
|
2498
|
+
}, ...(ngDevMode ? [{ debugName: "describedByIds" }] : []));
|
|
2384
2499
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2500
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormGroupWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2501
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.1", type: FormGroupWrapperComponent, isStandalone: true, selector: "ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]", 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: ["formGroupWrapper", "ngxFormGroupWrapper"], hostDirectives: [{ directive: FormErrorDisplayDirective, inputs: ["errorDisplayMode", "errorDisplayMode"] }], 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.warnings().length > 0) {\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 }); }
|
|
2502
|
+
}
|
|
2503
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormGroupWrapperComponent, decorators: [{
|
|
2504
|
+
type: Component,
|
|
2505
|
+
args: [{ selector: 'ngx-form-group-wrapper, sc-form-group-wrapper, [ngxFormGroupWrapper], [scFormGroupWrapper], [ngx-form-group-wrapper], [sc-form-group-wrapper]', exportAs: 'formGroupWrapper, ngxFormGroupWrapper', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
2506
|
+
class: 'ngx-form-group-wrapper sc-form-group-wrapper',
|
|
2507
|
+
'[class.ngx-form-group-wrapper--invalid]': 'errorDisplay.shouldShowErrors()',
|
|
2508
|
+
'[attr.aria-busy]': "errorDisplay.isPending() ? 'true' : null",
|
|
2509
|
+
}, hostDirectives: [
|
|
2510
|
+
{
|
|
2511
|
+
directive: FormErrorDisplayDirective,
|
|
2512
|
+
inputs: ['errorDisplayMode'],
|
|
2513
|
+
},
|
|
2514
|
+
], 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.warnings().length > 0) {\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"] }]
|
|
2515
|
+
}], propDecorators: { pendingDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingDebounce", required: false }] }] } });
|
|
2516
|
+
|
|
2517
|
+
let nextUniqueId = 0;
|
|
2518
|
+
/**
|
|
2519
|
+
* Wires a control container to its error/warning/pending regions.
|
|
2520
|
+
*
|
|
2521
|
+
* This directive is intended for custom wrappers/components.
|
|
2522
|
+
* It composes `FormErrorDisplayDirective` (and thus `FormControlStateDirective`)
|
|
2523
|
+
* and applies `aria-invalid` / `aria-describedby` to descendant controls.
|
|
2524
|
+
*
|
|
2525
|
+
* It does not render any UI; you can use the generated IDs to render messages.
|
|
2526
|
+
*/
|
|
2527
|
+
class FormErrorControlDirective {
|
|
2528
|
+
mergeAriaDescribedBy(existing, activeIds) {
|
|
2529
|
+
const existingTokens = (existing ?? '')
|
|
2530
|
+
.split(/\s+/)
|
|
2531
|
+
.map((t) => t.trim())
|
|
2532
|
+
.filter(Boolean);
|
|
2533
|
+
const existingWithoutOwned = existingTokens.filter((t) => !this.ownedDescribedByIds.includes(t));
|
|
2534
|
+
const merged = [...existingWithoutOwned];
|
|
2535
|
+
for (const id of activeIds) {
|
|
2536
|
+
if (!merged.includes(id)) {
|
|
2537
|
+
merged.push(id);
|
|
2538
|
+
}
|
|
2398
2539
|
}
|
|
2399
|
-
|
|
2400
|
-
return form.statusChanges.pipe(startWith(form.status), filter(() => dependents.every((depField) => !!form.get(depField))), take(1), map(() => control));
|
|
2540
|
+
return merged.length > 0 ? merged.join(' ') : null;
|
|
2401
2541
|
}
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2542
|
+
constructor() {
|
|
2543
|
+
this.errorDisplay = inject(FormErrorDisplayDirective, {
|
|
2544
|
+
self: true,
|
|
2545
|
+
});
|
|
2546
|
+
this.elementRef = inject((ElementRef));
|
|
2547
|
+
/**
|
|
2548
|
+
* Controls how this directive applies ARIA attributes to descendant controls.
|
|
2549
|
+
*
|
|
2550
|
+
* - `all-controls` (default): apply ARIA attributes to all input/select/textarea descendants.
|
|
2551
|
+
* - `single-control`: apply ARIA attributes only when exactly one control is found.
|
|
2552
|
+
* - `none`: do not mutate descendant controls.
|
|
2553
|
+
*/
|
|
2554
|
+
this.ariaAssociationMode = input('all-controls', ...(ngDevMode ? [{ debugName: "ariaAssociationMode" }] : []));
|
|
2555
|
+
/**
|
|
2556
|
+
* Unique ID prefix for this instance.
|
|
2557
|
+
* Use these IDs to render message regions and to support aria-describedby.
|
|
2558
|
+
*/
|
|
2559
|
+
this.uniqueId = `ngx-error-control-${nextUniqueId++}`;
|
|
2560
|
+
this.errorId = `${this.uniqueId}-error`;
|
|
2561
|
+
this.warningId = `${this.uniqueId}-warning`;
|
|
2562
|
+
this.pendingId = `${this.uniqueId}-pending`;
|
|
2563
|
+
this.formControls = signal([], ...(ngDevMode ? [{ debugName: "formControls" }] : []));
|
|
2564
|
+
this.contentInitialized = signal(false, ...(ngDevMode ? [{ debugName: "contentInitialized" }] : []));
|
|
2565
|
+
this.mutationObserver = null;
|
|
2566
|
+
this.pendingState = createDebouncedPendingState(this.errorDisplay.isPending, { showAfter: 500, minimumDisplay: 500 });
|
|
2567
|
+
this.showPendingMessage = this.pendingState.showPendingMessage;
|
|
2568
|
+
/**
|
|
2569
|
+
* aria-describedby value representing the *currently relevant* message regions.
|
|
2570
|
+
*/
|
|
2571
|
+
this.ariaDescribedBy = computed(() => {
|
|
2572
|
+
const ids = [];
|
|
2573
|
+
if (this.errorDisplay.shouldShowErrors()) {
|
|
2574
|
+
ids.push(this.errorId);
|
|
2422
2575
|
}
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
this.
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2576
|
+
if (this.errorDisplay.warnings().length > 0) {
|
|
2577
|
+
ids.push(this.warningId);
|
|
2578
|
+
}
|
|
2579
|
+
if (this.showPendingMessage()) {
|
|
2580
|
+
ids.push(this.pendingId);
|
|
2581
|
+
}
|
|
2582
|
+
return ids.length > 0 ? ids.join(' ') : null;
|
|
2583
|
+
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
|
|
2584
|
+
this.ownedDescribedByIds = [
|
|
2585
|
+
this.errorId,
|
|
2586
|
+
this.warningId,
|
|
2587
|
+
this.pendingId,
|
|
2588
|
+
];
|
|
2589
|
+
// Effect for ARIA attribute updates
|
|
2590
|
+
effect(() => {
|
|
2591
|
+
if (!this.contentInitialized())
|
|
2592
|
+
return;
|
|
2593
|
+
const mode = this.ariaAssociationMode();
|
|
2594
|
+
if (mode === 'none')
|
|
2595
|
+
return;
|
|
2596
|
+
const describedBy = this.ariaDescribedBy();
|
|
2597
|
+
const activeIds = describedBy
|
|
2598
|
+
? describedBy.split(/\s+/).filter(Boolean)
|
|
2599
|
+
: [];
|
|
2600
|
+
const shouldShowErrors = this.errorDisplay.shouldShowErrors();
|
|
2601
|
+
const targets = (() => {
|
|
2602
|
+
const controls = this.formControls();
|
|
2603
|
+
if (mode === 'single-control') {
|
|
2604
|
+
return controls.length === 1 ? controls : [];
|
|
2605
|
+
}
|
|
2606
|
+
return controls;
|
|
2607
|
+
})();
|
|
2608
|
+
for (const control of targets) {
|
|
2609
|
+
const nextDescribedBy = this.mergeAriaDescribedBy(control.getAttribute('aria-describedby'), activeIds);
|
|
2610
|
+
if (nextDescribedBy) {
|
|
2611
|
+
control.setAttribute('aria-describedby', nextDescribedBy);
|
|
2612
|
+
}
|
|
2613
|
+
else {
|
|
2614
|
+
control.removeAttribute('aria-describedby');
|
|
2615
|
+
}
|
|
2616
|
+
if (shouldShowErrors) {
|
|
2617
|
+
control.setAttribute('aria-invalid', 'true');
|
|
2618
|
+
}
|
|
2619
|
+
else {
|
|
2620
|
+
control.removeAttribute('aria-invalid');
|
|
2432
2621
|
}
|
|
2433
|
-
// emitEvent: true is REQUIRED for async validators to actually run
|
|
2434
|
-
// The validationInProgress Set prevents infinite loops:
|
|
2435
|
-
// 1. Field A changes → triggers validation on dependent field B
|
|
2436
|
-
// 2. B is added to validationInProgress Set
|
|
2437
|
-
// 3. B's statusChanges emits → #handleValueChange checks validationInProgress
|
|
2438
|
-
// 4. Since B is in validationInProgress, its validationConfig is not triggered
|
|
2439
|
-
// 5. After 500ms timeout, B is removed from validationInProgress
|
|
2440
|
-
// This way:
|
|
2441
|
-
// - Async validators CAN run (emitEvent: true)
|
|
2442
|
-
// - BUT circular triggers are prevented (validationInProgress check)
|
|
2443
|
-
dependentControl.updateValueAndValidity({
|
|
2444
|
-
onlySelf: true,
|
|
2445
|
-
emitEvent: true, // Changed from false - REQUIRED for validators to run!
|
|
2446
|
-
});
|
|
2447
|
-
// CRITICAL: Force immediate change detection for OnPush components
|
|
2448
|
-
// updateValueAndValidity updates the control's status, but doesn't automatically
|
|
2449
|
-
// trigger change detection. Components using OnPush won't see the ng-invalid class
|
|
2450
|
-
// update in the DOM without this. Using detectChanges() instead of markForCheck()
|
|
2451
|
-
// to force immediate synchronous update rather than waiting for next CD cycle.
|
|
2452
|
-
this.cdr.detectChanges();
|
|
2453
2622
|
}
|
|
2454
|
-
}
|
|
2455
|
-
//
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
this.
|
|
2623
|
+
});
|
|
2624
|
+
// Effect for MutationObserver setup with proper cleanup
|
|
2625
|
+
effect((onCleanup) => {
|
|
2626
|
+
if (!this.contentInitialized())
|
|
2627
|
+
return;
|
|
2628
|
+
const mode = this.ariaAssociationMode();
|
|
2629
|
+
if (mode === 'none') {
|
|
2630
|
+
this.mutationObserver?.disconnect();
|
|
2631
|
+
this.mutationObserver = null;
|
|
2632
|
+
if (this.formControls().length > 0) {
|
|
2633
|
+
this.formControls.set([]);
|
|
2634
|
+
}
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
this.updateFormControls();
|
|
2638
|
+
if (!this.mutationObserver) {
|
|
2639
|
+
this.mutationObserver = new MutationObserver(() => {
|
|
2640
|
+
this.updateFormControls();
|
|
2641
|
+
});
|
|
2642
|
+
this.mutationObserver.observe(this.elementRef.nativeElement, {
|
|
2643
|
+
childList: true,
|
|
2644
|
+
subtree: true,
|
|
2645
|
+
});
|
|
2464
2646
|
}
|
|
2465
|
-
|
|
2647
|
+
// Proper cleanup using onCleanup callback (Angular 21 best practice)
|
|
2648
|
+
onCleanup(() => {
|
|
2649
|
+
this.mutationObserver?.disconnect();
|
|
2650
|
+
this.mutationObserver = null;
|
|
2651
|
+
});
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
ngAfterContentInit() {
|
|
2655
|
+
this.contentInitialized.set(true);
|
|
2656
|
+
}
|
|
2657
|
+
ngOnDestroy() {
|
|
2658
|
+
this.mutationObserver?.disconnect();
|
|
2659
|
+
this.mutationObserver = null;
|
|
2466
2660
|
}
|
|
2467
|
-
|
|
2468
|
-
|
|
2661
|
+
updateFormControls() {
|
|
2662
|
+
const controls = this.elementRef.nativeElement.querySelectorAll('input, select, textarea');
|
|
2663
|
+
this.formControls.set(Array.from(controls));
|
|
2664
|
+
}
|
|
2665
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2666
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", 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"] }], ngImport: i0 }); }
|
|
2469
2667
|
}
|
|
2470
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.
|
|
2668
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormErrorControlDirective, decorators: [{
|
|
2471
2669
|
type: Directive,
|
|
2472
2670
|
args: [{
|
|
2473
|
-
selector: '
|
|
2474
|
-
exportAs: '
|
|
2671
|
+
selector: '[formErrorControl], [ngxErrorControl]',
|
|
2672
|
+
exportAs: 'formErrorControl, ngxErrorControl',
|
|
2673
|
+
hostDirectives: [
|
|
2674
|
+
{
|
|
2675
|
+
directive: FormErrorDisplayDirective,
|
|
2676
|
+
inputs: ['errorDisplayMode'],
|
|
2677
|
+
},
|
|
2678
|
+
],
|
|
2475
2679
|
}]
|
|
2476
|
-
}], ctorParameters: () => [], propDecorators: {
|
|
2680
|
+
}], ctorParameters: () => [], propDecorators: { ariaAssociationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaAssociationMode", required: false }] }] } });
|
|
2477
2681
|
|
|
2478
2682
|
/**
|
|
2479
2683
|
* Hooks into the ngModelGroup selector and triggers an asynchronous validation for a form group
|
|
@@ -2511,8 +2715,8 @@ class FormModelGroupDirective {
|
|
|
2511
2715
|
return of(null);
|
|
2512
2716
|
}
|
|
2513
2717
|
}
|
|
2514
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.
|
|
2515
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
2718
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelGroupDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2719
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormModelGroupDirective, isStandalone: true, selector: "[ngModelGroup],[ngxModelGroup]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
2516
2720
|
{
|
|
2517
2721
|
provide: NG_ASYNC_VALIDATORS,
|
|
2518
2722
|
useExisting: FormModelGroupDirective,
|
|
@@ -2520,7 +2724,7 @@ class FormModelGroupDirective {
|
|
|
2520
2724
|
},
|
|
2521
2725
|
], ngImport: i0 }); }
|
|
2522
2726
|
}
|
|
2523
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.
|
|
2727
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelGroupDirective, decorators: [{
|
|
2524
2728
|
type: Directive,
|
|
2525
2729
|
args: [{
|
|
2526
2730
|
selector: '[ngModelGroup],[ngxModelGroup]',
|
|
@@ -2578,8 +2782,8 @@ class FormModelDirective {
|
|
|
2578
2782
|
return of(null);
|
|
2579
2783
|
}
|
|
2580
2784
|
}
|
|
2581
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.
|
|
2582
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
2785
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
2786
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", type: FormModelDirective, isStandalone: true, selector: "[ngModel],[ngxModel]", inputs: { validationOptions: { classPropertyName: "validationOptions", publicName: "validationOptions", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
2583
2787
|
{
|
|
2584
2788
|
provide: NG_ASYNC_VALIDATORS,
|
|
2585
2789
|
useExisting: FormModelDirective,
|
|
@@ -2587,7 +2791,7 @@ class FormModelDirective {
|
|
|
2587
2791
|
},
|
|
2588
2792
|
], ngImport: i0 }); }
|
|
2589
2793
|
}
|
|
2590
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.
|
|
2794
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: FormModelDirective, decorators: [{
|
|
2591
2795
|
type: Directive,
|
|
2592
2796
|
args: [{
|
|
2593
2797
|
selector: '[ngModel],[ngxModel]',
|
|
@@ -2824,8 +3028,8 @@ class ValidateRootFormDirective {
|
|
|
2824
3028
|
}), take(1), takeUntilDestroyed(this.destroyRef));
|
|
2825
3029
|
};
|
|
2826
3030
|
}
|
|
2827
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.
|
|
2828
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.
|
|
3031
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ValidateRootFormDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
3032
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", 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: [
|
|
2829
3033
|
{
|
|
2830
3034
|
provide: NG_ASYNC_VALIDATORS,
|
|
2831
3035
|
useExisting: ValidateRootFormDirective,
|
|
@@ -2833,7 +3037,7 @@ class ValidateRootFormDirective {
|
|
|
2833
3037
|
},
|
|
2834
3038
|
], ngImport: i0 }); }
|
|
2835
3039
|
}
|
|
2836
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.
|
|
3040
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ValidateRootFormDirective, decorators: [{
|
|
2837
3041
|
type: Directive,
|
|
2838
3042
|
args: [{
|
|
2839
3043
|
selector: 'form[validateRootForm], form[ngxValidateRootForm]',
|
|
@@ -3693,5 +3897,5 @@ function keepFieldsWhen(currentState, conditions) {
|
|
|
3693
3897
|
* Generated bundle index. Do not edit.
|
|
3694
3898
|
*/
|
|
3695
3899
|
|
|
3696
|
-
export { ControlWrapperComponent, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeValuesAndRawValues, objectToArray, parseFieldPath, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
|
|
3900
|
+
export { ControlWrapperComponent, FormControlStateDirective, FormDirective, FormErrorControlDirective, FormErrorDisplayDirective, FormGroupWrapperComponent, FormModelDirective, FormModelGroupDirective, NGX_ERROR_DISPLAY_MODE_TOKEN, NGX_VALIDATION_CONFIG_DEBOUNCE_TOKEN, NGX_WARNING_DISPLAY_MODE_TOKEN, NgxVestForms, ROOT_FORM, ROOT_FORM as ROOT_FORM_CONSTANT, SC_ERROR_DISPLAY_MODE_TOKEN, ValidateRootFormDirective, ValidationConfigBuilder, arrayToObject, clearFields, clearFieldsWhen, cloneDeep, createDebouncedPendingState, createEmptyFormState, createValidationConfig, deepArrayToObject, fastDeepEqual, getAllFormErrors, getFormControlField, getFormGroupField, keepFieldsWhen, mergeValuesAndRawValues, objectToArray, parseFieldPath, set, setValueAtPath, shallowEqual, stringifyFieldPath, validateShape, vestForms, vestFormsViewProviders };
|
|
3697
3901
|
//# sourceMappingURL=ngx-vest-forms.mjs.map
|