@dynamic-field-kit/angular 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@ import * as i0 from '@angular/core';
2
2
  import { EventEmitter, Output, Input, Component, InjectionToken, inject, ViewContainerRef, ViewChild, ChangeDetectionStrategy, HostListener, signal, computed, NgModule } from '@angular/core';
3
3
  import * as i1 from '@angular/common';
4
4
  import { CommonModule, NgIf, NgClass, NgFor, JsonPipe } from '@angular/common';
5
- import { fieldRegistry, applyComputedValues, validateFields, resolveOptions, resolveDisabled, resolveReadOnly, validateField, canAddGroupItem, canRemoveGroupItem, createGroupItem } from '@dynamic-field-kit/core';
6
- export { FieldRegistry, fieldRegistry, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
5
+ import { fieldRegistry, makeErrorId, isAsyncOptions, createOptionsLoader, buildFieldRendererProps, makeFieldId, indexGroupPathMap, applyComputedValues, validateFields, canAddGroupItem, canRemoveGroupItem, createGroupItem, createMessageResolver, validateFieldsAsync, collectFieldPaths } from '@dynamic-field-kit/core';
6
+ export { FIELD_RENDERER_PROP_KEYS, FieldRegistry, buildFieldRendererProps, collectFieldPaths, fieldRegistry, indexGroupPathMap, makeErrorId, makeFieldId, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
7
7
 
8
8
  class BaseInputComponent {
9
9
  cdr;
@@ -13,10 +13,31 @@ class BaseInputComponent {
13
13
  required;
14
14
  disabled;
15
15
  readOnly;
16
+ /**
17
+ * Whether the field has been blurred (or marked touched by the form store).
18
+ * Renderers gate error display on this to avoid shouting at a user who has
19
+ * not reached the field yet.
20
+ */
21
+ touched;
22
+ /** Whether the value differs from the one the form opened with. */
23
+ dirty;
16
24
  error;
17
25
  options;
26
+ optionsStatus;
27
+ optionsError;
28
+ /** Renderer-driven refetch for a search-remote field. */
29
+ onOptionsQuery;
18
30
  className;
19
31
  description;
32
+ id;
33
+ ariaInvalid;
34
+ ariaDescribedBy;
35
+ ariaRequired;
36
+ min;
37
+ max;
38
+ step;
39
+ accept;
40
+ multiple;
20
41
  valueChange = new EventEmitter();
21
42
  constructor(cdr) {
22
43
  this.cdr = cdr;
@@ -30,10 +51,10 @@ class BaseInputComponent {
30
51
  hasChanges(changes, prop) {
31
52
  return changes[prop] !== undefined;
32
53
  }
33
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: BaseInputComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
34
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: BaseInputComponent, isStandalone: true, selector: "ng-component", inputs: { value: "value", label: "label", placeholder: "placeholder", required: "required", disabled: "disabled", readOnly: "readOnly", error: "error", options: "options", className: "className", description: "description" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: '', isInline: true });
54
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BaseInputComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
55
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: BaseInputComponent, isStandalone: true, selector: "ng-component", inputs: { value: "value", label: "label", placeholder: "placeholder", required: "required", disabled: "disabled", readOnly: "readOnly", touched: "touched", dirty: "dirty", error: "error", options: "options", optionsStatus: "optionsStatus", optionsError: "optionsError", onOptionsQuery: "onOptionsQuery", className: "className", description: "description", id: "id", ariaInvalid: "ariaInvalid", ariaDescribedBy: "ariaDescribedBy", ariaRequired: "ariaRequired", min: "min", max: "max", step: "step", accept: "accept", multiple: "multiple" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: '', isInline: true });
35
56
  }
36
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: BaseInputComponent, decorators: [{
57
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BaseInputComponent, decorators: [{
37
58
  type: Component,
38
59
  args: [{
39
60
  template: '',
@@ -50,14 +71,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
50
71
  type: Input
51
72
  }], readOnly: [{
52
73
  type: Input
74
+ }], touched: [{
75
+ type: Input
76
+ }], dirty: [{
77
+ type: Input
53
78
  }], error: [{
54
79
  type: Input
55
80
  }], options: [{
56
81
  type: Input
82
+ }], optionsStatus: [{
83
+ type: Input
84
+ }], optionsError: [{
85
+ type: Input
86
+ }], onOptionsQuery: [{
87
+ type: Input
57
88
  }], className: [{
58
89
  type: Input
59
90
  }], description: [{
60
91
  type: Input
92
+ }], id: [{
93
+ type: Input
94
+ }], ariaInvalid: [{
95
+ type: Input
96
+ }], ariaDescribedBy: [{
97
+ type: Input
98
+ }], ariaRequired: [{
99
+ type: Input
100
+ }], min: [{
101
+ type: Input
102
+ }], max: [{
103
+ type: Input
104
+ }], step: [{
105
+ type: Input
106
+ }], accept: [{
107
+ type: Input
108
+ }], multiple: [{
109
+ type: Input
61
110
  }], valueChange: [{
62
111
  type: Output
63
112
  }] } });
@@ -74,6 +123,10 @@ const FIELD_REGISTRY = new InjectionToken('dfk.FieldRegistry', {
74
123
 
75
124
  // Only the framework-agnostic FieldRendererProps keys. Domain-specific props
76
125
  // reach the renderer through `extraProps` (FieldDescription.props) instead.
126
+ // Must stay in step with core's FIELD_RENDERER_PROP_KEYS - a key missing here
127
+ // is a key no Angular renderer can ever receive, which is how `touched`,
128
+ // `dirty`, `id` and the aria flags used to be unreachable on this adapter
129
+ // alone. `packages/core/test/adapterParity.test.ts` asserts the two lists match.
77
130
  const KNOWN_PROPS = [
78
131
  'value',
79
132
  'label',
@@ -81,10 +134,23 @@ const KNOWN_PROPS = [
81
134
  'required',
82
135
  'disabled',
83
136
  'readOnly',
137
+ 'touched',
138
+ 'dirty',
84
139
  'error',
85
140
  'options',
141
+ 'optionsStatus',
142
+ 'optionsError',
86
143
  'className',
87
144
  'description',
145
+ 'id',
146
+ 'ariaInvalid',
147
+ 'ariaDescribedBy',
148
+ 'ariaRequired',
149
+ 'min',
150
+ 'max',
151
+ 'step',
152
+ 'accept',
153
+ 'multiple',
88
154
  ];
89
155
  class DynamicInput extends BaseInputComponent {
90
156
  type;
@@ -132,6 +198,9 @@ class DynamicInput extends BaseInputComponent {
132
198
  this.inputInstance[prop] = this[prop];
133
199
  }
134
200
  }
201
+ if (changes['onOptionsQuery']) {
202
+ this.applyCallbackProps(this.inputInstance);
203
+ }
135
204
  if (changes['extraProps']) {
136
205
  this.applyExtraProps(this.inputInstance);
137
206
  }
@@ -150,12 +219,33 @@ class DynamicInput extends BaseInputComponent {
150
219
  this.compRef = undefined;
151
220
  this.inputInstance = undefined;
152
221
  }
222
+ /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
223
+ errorNodeId() {
224
+ return makeErrorId(this.id ?? '');
225
+ }
226
+ /** `error` may arrive as a bare string, so index 0 would be a character. */
227
+ firstError() {
228
+ return Array.isArray(this.error) ? this.error[0] : this.error;
229
+ }
230
+ /**
231
+ * Whether the adapter should render the validation message itself.
232
+ *
233
+ * Asks the registry directly rather than reading a flag set by `render()`:
234
+ * `render()` runs in `ngAfterViewInit`, by which point this template's
235
+ * bindings have already been checked for the pass, and under `OnPush`
236
+ * nothing would mark them dirty again. A registered renderer owns its own
237
+ * error presentation, so only the built-in fallback gets a message here.
238
+ */
239
+ showDefaultError() {
240
+ return Boolean(!this.registry.get(this.type) && this.firstError() && this.id);
241
+ }
153
242
  render() {
154
243
  const Renderer = this.registry.get(this.type);
155
244
  this.cleanup();
156
245
  this.host.clear();
157
246
  if (!Renderer) {
158
247
  if (this.renderDefaultFallbackHTML5(this.type)) {
248
+ this.applyFallbackAria();
159
249
  return;
160
250
  }
161
251
  this.renderError(`Unknown field type: ${this.type}`);
@@ -219,15 +309,27 @@ class DynamicInput extends BaseInputComponent {
219
309
  ...this.extraProps,
220
310
  value: this.value,
221
311
  onValueChange: (v) => this.emitValue(v),
312
+ onOptionsQuery: this.onOptionsQuery,
222
313
  label: this.label ?? '',
223
314
  placeholder: this.placeholder ?? '',
224
315
  required: this.required ?? false,
225
316
  disabled: this.disabled ?? false,
226
317
  readOnly: this.readOnly ?? false,
318
+ touched: this.touched ?? false,
319
+ dirty: this.dirty ?? false,
227
320
  error: this.error,
228
321
  options: this.options ?? [],
229
322
  className: this.className ?? '',
230
323
  description: this.description ?? '',
324
+ id: this.id ?? '',
325
+ ariaInvalid: this.ariaInvalid ?? false,
326
+ ariaDescribedBy: this.ariaDescribedBy,
327
+ ariaRequired: this.ariaRequired ?? false,
328
+ min: this.min,
329
+ max: this.max,
330
+ step: this.step,
331
+ accept: this.accept,
332
+ multiple: this.multiple ?? false,
231
333
  };
232
334
  }
233
335
  // Gates on `this.supplied` (populated from SimpleChanges in ngOnChanges),
@@ -255,8 +357,22 @@ class DynamicInput extends BaseInputComponent {
255
357
  instanceObj[prop] = this[prop];
256
358
  }
257
359
  }
360
+ this.applyCallbackProps(instance);
258
361
  this.applyExtraProps(instance);
259
362
  }
363
+ /**
364
+ * Props that are callbacks rather than resolved values, so they are not in
365
+ * `KNOWN_PROPS` / `FIELD_RENDERER_PROP_KEYS` and the loop above never sees
366
+ * them. Without this a renderer declaring `onOptionsQuery` always got
367
+ * `undefined` and could never trigger a search-remote refetch.
368
+ */
369
+ applyCallbackProps(instance) {
370
+ if (!instance) {
371
+ return;
372
+ }
373
+ instance['onOptionsQuery'] =
374
+ this.onOptionsQuery;
375
+ }
260
376
  applyExtraProps(instance) {
261
377
  if (!instance || !this.extraProps) {
262
378
  return;
@@ -283,6 +399,35 @@ class DynamicInput extends BaseInputComponent {
283
399
  this.valueChange.emit(value);
284
400
  this.onChange.emit(value);
285
401
  }
402
+ /**
403
+ * Puts the aria flags on whatever control the HTML5 fallback just built.
404
+ *
405
+ * Applied here, once, rather than in each of the fallback's branches: they
406
+ * hand-build an input, textarea, checkbox or select, and every one of them
407
+ * would otherwise need the same four lines. Without this the fallback emits
408
+ * the `{id}-error` node with nothing pointing at it, and
409
+ * `focusFirstInvalidField` - which selects `[aria-invalid="true"]` - still
410
+ * finds nothing on this adapter.
411
+ *
412
+ * `render()` re-runs on every ngOnChanges while the fallback is in use (there
413
+ * is no component instance to sync props into), so this stays current as the
414
+ * field's error comes and goes.
415
+ */
416
+ applyFallbackAria() {
417
+ const control = this.host.element.nativeElement.querySelector('input, select, textarea');
418
+ if (!control) {
419
+ return;
420
+ }
421
+ if (this.ariaInvalid !== undefined) {
422
+ control.setAttribute('aria-invalid', String(this.ariaInvalid));
423
+ }
424
+ if (this.ariaRequired !== undefined) {
425
+ control.setAttribute('aria-required', String(this.ariaRequired));
426
+ }
427
+ if (this.ariaDescribedBy) {
428
+ control.setAttribute('aria-describedby', this.ariaDescribedBy);
429
+ }
430
+ }
286
431
  renderDefaultFallbackHTML5(type) {
287
432
  const nativeEl = this.host.element.nativeElement;
288
433
  if (type === 'text' ||
@@ -291,6 +436,9 @@ class DynamicInput extends BaseInputComponent {
291
436
  type === 'number') {
292
437
  const input = document.createElement('input');
293
438
  input.type = type;
439
+ if (this.id) {
440
+ input.id = this.id;
441
+ }
294
442
  if (this.className) {
295
443
  input.className = this.className;
296
444
  }
@@ -317,6 +465,9 @@ class DynamicInput extends BaseInputComponent {
317
465
  }
318
466
  if (type === 'textarea') {
319
467
  const textarea = document.createElement('textarea');
468
+ if (this.id) {
469
+ textarea.id = this.id;
470
+ }
320
471
  if (this.className) {
321
472
  textarea.className = this.className;
322
473
  }
@@ -342,6 +493,9 @@ class DynamicInput extends BaseInputComponent {
342
493
  if (type === 'checkbox') {
343
494
  const checkbox = document.createElement('input');
344
495
  checkbox.type = 'checkbox';
496
+ if (this.id) {
497
+ checkbox.id = this.id;
498
+ }
345
499
  if (this.className) {
346
500
  checkbox.className = this.className;
347
501
  }
@@ -360,6 +514,9 @@ class DynamicInput extends BaseInputComponent {
360
514
  }
361
515
  if (type === 'select') {
362
516
  const select = document.createElement('select');
517
+ if (this.id) {
518
+ select.id = this.id;
519
+ }
363
520
  if (this.className) {
364
521
  select.className = this.className;
365
522
  }
@@ -401,17 +558,35 @@ class DynamicInput extends BaseInputComponent {
401
558
  el.style.color = 'red';
402
559
  this.host.element.nativeElement.appendChild(el);
403
560
  }
404
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicInput, deps: null, target: i0.ɵɵFactoryTarget.Component });
405
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: DynamicInput, isStandalone: true, selector: "dfk-dynamic-input", inputs: { type: "type", extraProps: "extraProps" }, outputs: { valueChange: "valueChange", onChange: "onChange" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, read: ViewContainerRef }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: `<div #host style="display: contents;"></div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
561
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicInput, deps: null, target: i0.ɵɵFactoryTarget.Component });
562
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicInput, isStandalone: true, selector: "dfk-dynamic-input", inputs: { type: "type", extraProps: "extraProps" }, outputs: { valueChange: "valueChange", onChange: "onChange" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, read: ViewContainerRef }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: `<div #host style="display: contents;"></div>
563
+ <div
564
+ *ngIf="showDefaultError()"
565
+ [id]="errorNodeId()"
566
+ class="dfk-field-error"
567
+ role="alert"
568
+ >
569
+ {{ firstError() }}
570
+ </div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
406
571
  }
407
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicInput, decorators: [{
572
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicInput, decorators: [{
408
573
  type: Component,
409
574
  args: [{
410
575
  selector: 'dfk-dynamic-input',
411
576
  standalone: true,
412
577
  imports: [CommonModule],
413
578
  changeDetection: ChangeDetectionStrategy.OnPush,
414
- template: `<div #host style="display: contents;"></div>`,
579
+ // *ngIf rather than @if: the peer range starts at Angular 16, and the
580
+ // built-in control flow block syntax is 17+.
581
+ template: `<div #host style="display: contents;"></div>
582
+ <div
583
+ *ngIf="showDefaultError()"
584
+ [id]="errorNodeId()"
585
+ class="dfk-field-error"
586
+ role="alert"
587
+ >
588
+ {{ firstError() }}
589
+ </div>`,
415
590
  }]
416
591
  }], propDecorators: { type: [{
417
592
  type: Input
@@ -429,11 +604,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
429
604
  class FieldInput {
430
605
  cdr;
431
606
  fieldDescription;
607
+ /**
608
+ * Data at this field's own level. Preferred over `value`: the shared
609
+ * `buildFieldRendererProps` needs the surrounding data to resolve
610
+ * `appearCondition`, dynamic options and cross-field validation. When only
611
+ * `value` is bound (mounting this component directly), a one-key object is
612
+ * synthesised from it.
613
+ */
614
+ data;
615
+ /** Top-level form data, for fields nested inside a repeatable group. */
616
+ rootData;
432
617
  value;
433
618
  options;
434
619
  disabled;
435
620
  readOnly;
436
621
  error;
622
+ /** Distinguishes a controlled empty error from an omitted error input. */
623
+ validationControlled = false;
624
+ /** Whether the field has been blurred, or marked touched by a form store. */
625
+ touched;
626
+ /** Whether the value differs from the one the form opened with. */
627
+ dirty;
628
+ /** Per-form-instance id namespace; see core's `makeFieldId`. */
629
+ idPrefix = 'dfk-field';
437
630
  onValueChangeField = new EventEmitter();
438
631
  /**
439
632
  * Emits this field's name when focus leaves it. Driven by `focusout`, which
@@ -441,50 +634,121 @@ class FieldInput {
441
634
  * declare a blur output of its own.
442
635
  */
443
636
  onBlurField = new EventEmitter();
444
- shouldRender = false;
445
- get resolvedOptions() {
446
- if (this.options) {
447
- return this.options;
448
- }
449
- if (!this.fieldDescription?.options) {
450
- return undefined;
451
- }
452
- if (typeof this.fieldDescription.options === 'function') {
453
- return undefined;
454
- }
455
- return this.fieldDescription.options;
456
- }
637
+ /**
638
+ * The full renderer prop bag, built once per change-detection pass rather
639
+ * than from one getter per binding - `buildFieldRendererProps` validates the
640
+ * field, and running that ~20 times per pass would be wasteful.
641
+ */
642
+ rendererProps = null;
457
643
  constructor(cdr) {
458
644
  this.cdr = cdr;
459
645
  }
646
+ // Async options only; a static or synchronous list allocates nothing here.
647
+ loader;
648
+ optionsState;
649
+ /** Bound into the template so a renderer can drive a search-remote refetch. */
650
+ onOptionsQuery = (query) => {
651
+ this.loader?.setQuery(query);
652
+ };
460
653
  ngOnChanges(_changes) {
461
- this.shouldRender = !!this.fieldDescription;
654
+ this.syncOptionsLoader();
655
+ this.rendererProps = this.buildProps();
462
656
  this.cdr.markForCheck();
463
657
  }
464
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: FieldInput, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
465
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: FieldInput, isStandalone: true, selector: "dfk-field-input", inputs: { fieldDescription: "fieldDescription", value: "value", options: "options", disabled: "disabled", readOnly: "readOnly", error: "error" }, outputs: { onValueChangeField: "onValueChangeField", onBlurField: "onBlurField" }, usesOnChanges: true, ngImport: i0, template: `
658
+ ngOnDestroy() {
659
+ this.loader?.dispose();
660
+ }
661
+ syncOptionsLoader() {
662
+ const field = this.fieldDescription;
663
+ if (!field || !isAsyncOptions(field)) {
664
+ // A field can be swapped for one with synchronous options. Without this,
665
+ // the stale optionsState would keep winning in buildFieldRendererProps
666
+ // (`optionsState ? optionsState.options : resolveOptions(...)`) and the
667
+ // old async list would be served forever, with the loader never disposed.
668
+ this.loader?.dispose();
669
+ this.loader = undefined;
670
+ this.optionsState = undefined;
671
+ return;
672
+ }
673
+ if (!this.loader) {
674
+ this.optionsState = { status: 'idle' };
675
+ this.loader = createOptionsLoader(field, (state) => {
676
+ this.optionsState = state;
677
+ this.rendererProps = this.buildProps();
678
+ // OnPush: an async arrival happens outside any event this view is
679
+ // checked for, so without this the options would load and never appear.
680
+ this.cdr.markForCheck();
681
+ });
682
+ }
683
+ this.loader.update(this.data ?? {}, this.rootData);
684
+ }
685
+ buildProps() {
686
+ const field = this.fieldDescription;
687
+ if (!field) {
688
+ return null;
689
+ }
690
+ const data = this.data ?? { [field.name]: this.value };
691
+ const base = buildFieldRendererProps({
692
+ fieldDescription: field,
693
+ data,
694
+ rootData: this.rootData,
695
+ id: makeFieldId(field, this.idPrefix),
696
+ touched: this.touched,
697
+ dirty: this.dirty,
698
+ optionsState: this.optionsState,
699
+ });
700
+ // Explicitly bound inputs override what the field description resolves to,
701
+ // so a host can still drive options/disabled/error itself.
702
+ const error = this.validationControlled
703
+ ? this.error
704
+ : (this.error ?? base.error);
705
+ return {
706
+ ...base,
707
+ options: this.options ?? base.options,
708
+ disabled: this.disabled ?? base.disabled,
709
+ readOnly: this.readOnly ?? base.readOnly,
710
+ error,
711
+ ariaInvalid: Boolean(error),
712
+ };
713
+ }
714
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FieldInput, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
715
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: FieldInput, isStandalone: true, selector: "dfk-field-input", inputs: { fieldDescription: "fieldDescription", data: "data", rootData: "rootData", value: "value", options: "options", disabled: "disabled", readOnly: "readOnly", error: "error", validationControlled: "validationControlled", touched: "touched", dirty: "dirty", idPrefix: "idPrefix" }, outputs: { onValueChangeField: "onValueChangeField", onBlurField: "onBlurField" }, usesOnChanges: true, ngImport: i0, template: `
466
716
  <dfk-dynamic-input
467
- *ngIf="shouldRender"
468
- [type]="fieldDescription!.type"
469
- [value]="value"
470
- [label]="fieldDescription!.label"
471
- [placeholder]="fieldDescription!.placeholder"
472
- [required]="fieldDescription!.required"
473
- [description]="$any(fieldDescription!.description)"
474
- [options]="resolvedOptions"
475
- [className]="fieldDescription!.className"
717
+ *ngIf="rendererProps as p"
718
+ [type]="p.type"
719
+ [value]="p.value"
720
+ [label]="p.label"
721
+ [placeholder]="p.placeholder"
722
+ [required]="p.required"
723
+ [description]="$any(p.description)"
724
+ [options]="$any(p.options)"
725
+ [optionsStatus]="p.optionsStatus"
726
+ [optionsError]="p.optionsError"
727
+ [className]="p.className"
728
+ [disabled]="p.disabled"
729
+ [readOnly]="p.readOnly"
730
+ [touched]="p.touched"
731
+ [dirty]="p.dirty"
732
+ [error]="$any(p.error)"
733
+ [id]="p.id"
734
+ [ariaInvalid]="p.ariaInvalid"
735
+ [ariaDescribedBy]="p.ariaDescribedBy"
736
+ [ariaRequired]="p.ariaRequired"
737
+ [min]="p.min"
738
+ [max]="p.max"
739
+ [step]="p.step"
740
+ [accept]="p.accept"
741
+ [multiple]="p.multiple"
476
742
  (valueChange)="
477
743
  onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
478
744
  "
479
745
  (focusout)="onBlurField.emit(fieldDescription!.name)"
480
- [disabled]="disabled"
481
- [readOnly]="readOnly"
482
- [error]="$any(error)"
483
- [extraProps]="fieldDescription!.props"
746
+ [extraProps]="p.extraProps"
747
+ [onOptionsQuery]="onOptionsQuery"
484
748
  ></dfk-dynamic-input>
485
749
  `, isInline: true, dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: DynamicInput, selector: "dfk-dynamic-input", inputs: ["type", "extraProps"], outputs: ["valueChange", "onChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
486
750
  }
487
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: FieldInput, decorators: [{
751
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FieldInput, decorators: [{
488
752
  type: Component,
489
753
  args: [{
490
754
  selector: 'dfk-field-input',
@@ -493,28 +757,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
493
757
  changeDetection: ChangeDetectionStrategy.OnPush,
494
758
  template: `
495
759
  <dfk-dynamic-input
496
- *ngIf="shouldRender"
497
- [type]="fieldDescription!.type"
498
- [value]="value"
499
- [label]="fieldDescription!.label"
500
- [placeholder]="fieldDescription!.placeholder"
501
- [required]="fieldDescription!.required"
502
- [description]="$any(fieldDescription!.description)"
503
- [options]="resolvedOptions"
504
- [className]="fieldDescription!.className"
760
+ *ngIf="rendererProps as p"
761
+ [type]="p.type"
762
+ [value]="p.value"
763
+ [label]="p.label"
764
+ [placeholder]="p.placeholder"
765
+ [required]="p.required"
766
+ [description]="$any(p.description)"
767
+ [options]="$any(p.options)"
768
+ [optionsStatus]="p.optionsStatus"
769
+ [optionsError]="p.optionsError"
770
+ [className]="p.className"
771
+ [disabled]="p.disabled"
772
+ [readOnly]="p.readOnly"
773
+ [touched]="p.touched"
774
+ [dirty]="p.dirty"
775
+ [error]="$any(p.error)"
776
+ [id]="p.id"
777
+ [ariaInvalid]="p.ariaInvalid"
778
+ [ariaDescribedBy]="p.ariaDescribedBy"
779
+ [ariaRequired]="p.ariaRequired"
780
+ [min]="p.min"
781
+ [max]="p.max"
782
+ [step]="p.step"
783
+ [accept]="p.accept"
784
+ [multiple]="p.multiple"
505
785
  (valueChange)="
506
786
  onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
507
787
  "
508
788
  (focusout)="onBlurField.emit(fieldDescription!.name)"
509
- [disabled]="disabled"
510
- [readOnly]="readOnly"
511
- [error]="$any(error)"
512
- [extraProps]="fieldDescription!.props"
789
+ [extraProps]="p.extraProps"
790
+ [onOptionsQuery]="onOptionsQuery"
513
791
  ></dfk-dynamic-input>
514
792
  `,
515
793
  }]
516
794
  }], ctorParameters: () => [{ type: i0.ChangeDetectorRef }], propDecorators: { fieldDescription: [{
517
795
  type: Input
796
+ }], data: [{
797
+ type: Input
798
+ }], rootData: [{
799
+ type: Input
518
800
  }], value: [{
519
801
  type: Input
520
802
  }], options: [{
@@ -525,6 +807,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
525
807
  type: Input
526
808
  }], error: [{
527
809
  type: Input
810
+ }], validationControlled: [{
811
+ type: Input
812
+ }], touched: [{
813
+ type: Input
814
+ }], dirty: [{
815
+ type: Input
816
+ }], idPrefix: [{
817
+ type: Input
528
818
  }], onValueChangeField: [{
529
819
  type: Output
530
820
  }], onBlurField: [{
@@ -532,10 +822,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
532
822
  }] } });
533
823
 
534
824
  const DEFAULT_BREAKPOINT = 768;
825
+ // Per-instance id counter, so two forms rendering the same field name emit
826
+ // different DOM ids.
827
+ /**
828
+ * Shared empty maps. A fresh object literal in a template binding is a new
829
+ * identity on every change detection pass, which defeats the per-item caching
830
+ * below and trips dev-mode checkNoChanges (NG0100).
831
+ */
832
+ const EMPTY_ERRORS = Object.freeze({});
833
+ const EMPTY_TOUCHED = Object.freeze({});
834
+ let instanceCounter = 0;
835
+ function nextInstanceId() {
836
+ instanceCounter += 1;
837
+ return instanceCounter;
838
+ }
535
839
  class MultiFieldInput {
536
840
  cdr;
537
841
  fieldDescriptions = [];
538
842
  properties;
843
+ /**
844
+ * The values per-field `dirty` is measured against. Defaults to the first
845
+ * non-`undefined` `properties` this component sees. This adapter has no
846
+ * `form` shorthand, so pass `store.baselineValues()` here to keep `dirty`
847
+ * correct across `store.reset(newValues)`.
848
+ */
849
+ initialProperties;
539
850
  onChange = new EventEmitter();
540
851
  validityChange = new EventEmitter();
541
852
  /**
@@ -545,14 +856,113 @@ class MultiFieldInput {
545
856
  * `validateOnBlur` behaviour.
546
857
  */
547
858
  onBlurField = new EventEmitter();
859
+ /**
860
+ * Controlled touched map. When bound it is the single source of truth and
861
+ * the internal tracker is bypassed, so `createDynamicFormStore`'s `touched`
862
+ * signal (updated by setFieldTouched/touchAll/handleSubmit, cleared by
863
+ * reset) is what renderers actually see. Leave it unbound to keep the
864
+ * internal, blur-only tracker.
865
+ */
866
+ touched;
867
+ /** Controlled error map; bind the form store's `errors()` signal here. */
868
+ errors;
869
+ /** Emits the next touched map whenever a field is blurred. */
870
+ touchedChange = new EventEmitter();
871
+ /**
872
+ * Namespace for generated field ids: a field renders with
873
+ * `${idPrefix}-${name}`. Defaults to a value unique to this component
874
+ * instance, so two forms containing the same field name do not emit
875
+ * duplicate DOM ids. Bind a fixed string to pin ids (`idPrefix="dfk-field"`
876
+ * restores the pre-1.6 ids), or set `FieldDescription.id` per field.
877
+ */
878
+ idPrefix;
548
879
  touchedFields = {};
880
+ // Unique per component instance. Angular has no useId equivalent, and this
881
+ // component is client-rendered by the time ids matter, so a module counter
882
+ // is enough.
883
+ instanceId = nextInstanceId();
884
+ firstSeenProperties = {};
885
+ indexedErrorsSource;
886
+ indexedErrors = new Map();
887
+ indexedTouchedSource;
888
+ indexedTouched = new Map();
889
+ get effectiveIdPrefix() {
890
+ return this.idPrefix ?? `dfk-${this.instanceId}`;
891
+ }
892
+ /** The touched map in effect: the controlled input, else the internal one. */
893
+ get effectiveTouched() {
894
+ return this.touched ?? this.touchedFields;
895
+ }
549
896
  handleBlurField(fieldName) {
550
- this.touchedFields = { ...this.touchedFields, [fieldName]: true };
897
+ if (this.touched === undefined) {
898
+ this.touchedFields = { ...this.touchedFields, [fieldName]: true };
899
+ }
900
+ this.touchedChange.emit({ ...this.effectiveTouched, [fieldName]: true });
551
901
  this.onBlurField.emit(fieldName);
902
+ this.cdr.markForCheck();
552
903
  }
553
904
  /** Whether this field has been blurred at least once. */
554
905
  isTouched(fieldName) {
555
- return Boolean(this.touchedFields[fieldName]);
906
+ return Boolean(this.effectiveTouched[fieldName]);
907
+ }
908
+ /** Whether this field's value differs from the one the form opened with. */
909
+ isFieldDirty(fieldName) {
910
+ const baseline = this.initialProperties ?? this.firstSeenProperties;
911
+ return !Object.is(this.data[fieldName], baseline[fieldName]);
912
+ }
913
+ fieldErrors(fieldName) {
914
+ return this.errors?.[fieldName];
915
+ }
916
+ errorsForItem(fieldName, index) {
917
+ if (this.indexedErrorsSource !== this.errors) {
918
+ this.indexedErrorsSource = this.errors;
919
+ this.indexedErrors.clear();
920
+ }
921
+ if (!this.indexedErrors.has(fieldName)) {
922
+ this.indexedErrors.set(fieldName, indexGroupPathMap(this.errors, fieldName));
923
+ }
924
+ // An item with no errors still gets a map when the parent has one, so a
925
+ // nested input is never handed `undefined` for a controlled error map.
926
+ return this.errors === undefined
927
+ ? undefined
928
+ : (this.indexedErrors.get(fieldName)?.[index] ?? EMPTY_ERRORS);
929
+ }
930
+ touchedForItem(fieldName, index) {
931
+ const current = this.effectiveTouched;
932
+ if (this.indexedTouchedSource !== current) {
933
+ this.indexedTouchedSource = current;
934
+ this.indexedTouched.clear();
935
+ }
936
+ if (!this.indexedTouched.has(fieldName)) {
937
+ this.indexedTouched.set(fieldName, indexGroupPathMap(current, fieldName));
938
+ }
939
+ // A fresh {} here would be a new binding identity on every change
940
+ // detection pass, re-running the nested input's ngOnChanges each time and
941
+ // risking NG0100 in dev mode.
942
+ return this.indexedTouched.get(fieldName)?.[index] ?? EMPTY_TOUCHED;
943
+ }
944
+ handleGroupBlur(fieldName, index, key) {
945
+ this.handleBlurField(`${fieldName}[${index}].${key}`);
946
+ }
947
+ /**
948
+ * Clears the internally tracked touched state. Only meaningful in
949
+ * uncontrolled mode - when `touched` is bound, resetting the form store
950
+ * (e.g. `createDynamicFormStore().reset()`) already clears it.
951
+ */
952
+ resetTouched() {
953
+ this.touchedFields = {};
954
+ this.touchedChange.emit(this.effectiveTouched);
955
+ this.cdr.markForCheck();
956
+ }
957
+ setFieldTouched(fieldName, isTouched = true) {
958
+ if (this.touched === undefined) {
959
+ this.touchedFields = { ...this.touchedFields, [fieldName]: isTouched };
960
+ }
961
+ this.touchedChange.emit({
962
+ ...this.effectiveTouched,
963
+ [fieldName]: isTouched,
964
+ });
965
+ this.cdr.markForCheck();
556
966
  }
557
967
  layout = 'column';
558
968
  // Top-level form data, threaded down through repeatable groups so a nested
@@ -561,6 +971,7 @@ class MultiFieldInput {
561
971
  rootData;
562
972
  data = {};
563
973
  visibleFields = [];
974
+ initialised = false;
564
975
  isMobile = false;
565
976
  constructor(cdr) {
566
977
  this.cdr = cdr;
@@ -637,6 +1048,12 @@ class MultiFieldInput {
637
1048
  init() {
638
1049
  if (this.properties) {
639
1050
  this.data = applyComputedValues(this.fieldDescriptions, { ...this.properties }, this.rootData);
1051
+ // Baseline for the `dirty` flag: the values the form opened with, not
1052
+ // whatever `properties` happens to hold after later edits.
1053
+ if (!this.initialised) {
1054
+ this.firstSeenProperties = { ...this.properties };
1055
+ this.initialised = true;
1056
+ }
640
1057
  }
641
1058
  this.updateVisibleFields();
642
1059
  this.validityChange.emit(validateFields(this.fieldDescriptions, this.data, this.rootData));
@@ -652,22 +1069,11 @@ class MultiFieldInput {
652
1069
  const value = this.data[field.name];
653
1070
  return Array.isArray(value) ? value : [];
654
1071
  }
655
- getResolvedOptions(field) {
656
- return resolveOptions(field, this.data, this.rootData);
657
- }
658
- getDisabled(field) {
659
- return resolveDisabled(field, this.data, this.rootData);
660
- }
661
- getReadOnly(field) {
662
- return resolveReadOnly(field, this.data, this.rootData);
663
- }
664
- getError(field) {
665
- if (this.getDisabled(field)) {
666
- return undefined;
667
- }
668
- const errors = validateField(field, this.data[field.name], this.data, this.rootData);
669
- return errors.length > 0 ? errors : undefined;
670
- }
1072
+ // The per-field getResolvedOptions/getDisabled/getReadOnly/getError helpers
1073
+ // that used to live here are gone: FieldInput now resolves all of it through
1074
+ // core's shared buildFieldRendererProps. Keeping a second copy of that logic
1075
+ // beside the shared one is exactly how the adapters drifted apart in the
1076
+ // first place.
671
1077
  canAddItem(field) {
672
1078
  return canAddGroupItem(field, this.getItems(field));
673
1079
  }
@@ -703,8 +1109,8 @@ class MultiFieldInput {
703
1109
  this.validityChange.emit(validateFields(this.fieldDescriptions, this.data, this.rootData));
704
1110
  this.cdr.markForCheck();
705
1111
  }
706
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: MultiFieldInput, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
707
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: MultiFieldInput, isStandalone: true, selector: "dfk-multi-field-input", inputs: { fieldDescriptions: "fieldDescriptions", properties: "properties", layout: "layout", rootData: "rootData" }, outputs: { onChange: "onChange", validityChange: "validityChange", onBlurField: "onBlurField" }, host: { listeners: { "window:resize": "onWindowResize()" } }, usesOnChanges: true, ngImport: i0, template: `
1112
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiFieldInput, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
1113
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: MultiFieldInput, isStandalone: true, selector: "dfk-multi-field-input", inputs: { fieldDescriptions: "fieldDescriptions", properties: "properties", initialProperties: "initialProperties", touched: "touched", errors: "errors", idPrefix: "idPrefix", layout: "layout", rootData: "rootData" }, outputs: { onChange: "onChange", validityChange: "validityChange", onBlurField: "onBlurField", touchedChange: "touchedChange" }, host: { listeners: { "window:resize": "onWindowResize()" } }, usesOnChanges: true, ngImport: i0, template: `
708
1114
  <div
709
1115
  class="p-4 border rounded-lg bg-gray-50"
710
1116
  [ngClass]="{
@@ -721,11 +1127,13 @@ class MultiFieldInput {
721
1127
  <dfk-field-input
722
1128
  *ngIf="!field.fields"
723
1129
  [fieldDescription]="field"
724
- [value]="data[field.name]"
725
- [options]="getResolvedOptions(field)"
726
- [disabled]="getDisabled(field)"
727
- [readOnly]="getReadOnly(field)"
728
- [error]="getError(field)"
1130
+ [data]="data"
1131
+ [rootData]="rootData"
1132
+ [idPrefix]="effectiveIdPrefix"
1133
+ [touched]="isTouched(field.name)"
1134
+ [dirty]="isFieldDirty(field.name)"
1135
+ [error]="fieldErrors(field.name)"
1136
+ [validationControlled]="errors !== undefined"
729
1137
  (onValueChangeField)="onFieldChange($event)"
730
1138
  (onBlurField)="handleBlurField($event)"
731
1139
  ></dfk-field-input>
@@ -745,6 +1153,9 @@ class MultiFieldInput {
745
1153
  [fieldDescriptions]="field.fields"
746
1154
  [properties]="item"
747
1155
  [rootData]="rootData ?? data"
1156
+ [errors]="errorsForItem(field.name, i)"
1157
+ [touched]="touchedForItem(field.name, i)"
1158
+ (onBlurField)="handleGroupBlur(field.name, i, $event)"
748
1159
  (onChange)="onGroupItemChange(field, i, $event)"
749
1160
  ></dfk-multi-field-input>
750
1161
  </div>
@@ -776,9 +1187,9 @@ class MultiFieldInput {
776
1187
  </div>
777
1188
  </ng-container>
778
1189
  </div>
779
- `, isInline: true, dependencies: [{ kind: "component", type: MultiFieldInput, selector: "dfk-multi-field-input", inputs: ["fieldDescriptions", "properties", "layout", "rootData"], outputs: ["onChange", "validityChange", "onBlurField"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: FieldInput, selector: "dfk-field-input", inputs: ["fieldDescription", "value", "options", "disabled", "readOnly", "error"], outputs: ["onValueChangeField", "onBlurField"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1190
+ `, isInline: true, dependencies: [{ kind: "component", type: MultiFieldInput, selector: "dfk-multi-field-input", inputs: ["fieldDescriptions", "properties", "initialProperties", "touched", "errors", "idPrefix", "layout", "rootData"], outputs: ["onChange", "validityChange", "onBlurField", "touchedChange"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: FieldInput, selector: "dfk-field-input", inputs: ["fieldDescription", "data", "rootData", "value", "options", "disabled", "readOnly", "error", "validationControlled", "touched", "dirty", "idPrefix"], outputs: ["onValueChangeField", "onBlurField"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
780
1191
  }
781
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: MultiFieldInput, decorators: [{
1192
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiFieldInput, decorators: [{
782
1193
  type: Component,
783
1194
  args: [{
784
1195
  selector: 'dfk-multi-field-input',
@@ -808,11 +1219,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
808
1219
  <dfk-field-input
809
1220
  *ngIf="!field.fields"
810
1221
  [fieldDescription]="field"
811
- [value]="data[field.name]"
812
- [options]="getResolvedOptions(field)"
813
- [disabled]="getDisabled(field)"
814
- [readOnly]="getReadOnly(field)"
815
- [error]="getError(field)"
1222
+ [data]="data"
1223
+ [rootData]="rootData"
1224
+ [idPrefix]="effectiveIdPrefix"
1225
+ [touched]="isTouched(field.name)"
1226
+ [dirty]="isFieldDirty(field.name)"
1227
+ [error]="fieldErrors(field.name)"
1228
+ [validationControlled]="errors !== undefined"
816
1229
  (onValueChangeField)="onFieldChange($event)"
817
1230
  (onBlurField)="handleBlurField($event)"
818
1231
  ></dfk-field-input>
@@ -832,6 +1245,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
832
1245
  [fieldDescriptions]="field.fields"
833
1246
  [properties]="item"
834
1247
  [rootData]="rootData ?? data"
1248
+ [errors]="errorsForItem(field.name, i)"
1249
+ [touched]="touchedForItem(field.name, i)"
1250
+ (onBlurField)="handleGroupBlur(field.name, i, $event)"
835
1251
  (onChange)="onGroupItemChange(field, i, $event)"
836
1252
  ></dfk-multi-field-input>
837
1253
  </div>
@@ -869,12 +1285,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImpo
869
1285
  type: Input
870
1286
  }], properties: [{
871
1287
  type: Input
1288
+ }], initialProperties: [{
1289
+ type: Input
872
1290
  }], onChange: [{
873
1291
  type: Output
874
1292
  }], validityChange: [{
875
1293
  type: Output
876
1294
  }], onBlurField: [{
877
1295
  type: Output
1296
+ }], touched: [{
1297
+ type: Input
1298
+ }], errors: [{
1299
+ type: Input
1300
+ }], touchedChange: [{
1301
+ type: Output
1302
+ }], idPrefix: [{
1303
+ type: Input
878
1304
  }], layout: [{
879
1305
  type: Input
880
1306
  }], rootData: [{
@@ -894,16 +1320,16 @@ class DynamicFormDevToolsComponent {
894
1320
  errorCount() {
895
1321
  return Object.keys(this.errors || {}).length;
896
1322
  }
897
- isOpen = signal(false);
898
- activeTab = signal('data');
1323
+ isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
1324
+ activeTab = signal('data', ...(ngDevMode ? [{ debugName: "activeTab" }] : /* istanbul ignore next */ []));
899
1325
  tabs = [
900
1326
  'data',
901
1327
  'errors',
902
1328
  'meta',
903
1329
  'fields',
904
1330
  ];
905
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicFormDevToolsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
906
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: DynamicFormDevToolsComponent, isStandalone: true, selector: "dfk-dev-tools", inputs: { data: "data", errors: "errors", touched: "touched", isDirty: "isDirty", fields: "fields" }, ngImport: i0, template: `
1331
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormDevToolsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1332
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicFormDevToolsComponent, isStandalone: true, selector: "dfk-dev-tools", inputs: { data: "data", errors: "errors", touched: "touched", isDirty: "isDirty", fields: "fields" }, ngImport: i0, template: `
907
1333
  <button
908
1334
  *ngIf="!isOpen()"
909
1335
  type="button"
@@ -1037,7 +1463,7 @@ class DynamicFormDevToolsComponent {
1037
1463
  </div>
1038
1464
  `, isInline: true, dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "pipe", type: JsonPipe, name: "json" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1039
1465
  }
1040
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicFormDevToolsComponent, decorators: [{
1466
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormDevToolsComponent, decorators: [{
1041
1467
  type: Component,
1042
1468
  args: [{
1043
1469
  selector: 'dfk-dev-tools',
@@ -1195,24 +1621,74 @@ function createDynamicFormStore(options) {
1195
1621
  const initialValues = options.initialValues || {};
1196
1622
  const validateOnBlur = options.validateOnBlur ?? true;
1197
1623
  const validateOnChange = options.validateOnChange ?? false;
1198
- const data = signal(applyComputedValues(fields, initialValues));
1199
- const errors = signal({});
1200
- const isDirty = signal(false);
1201
- const touched = signal({});
1202
- const isSubmitting = signal(false);
1203
- const isSubmitted = signal(false);
1204
- const isValid = computed(() => Object.keys(errors()).length === 0);
1624
+ const validationContext = {
1625
+ t: createMessageResolver(options.messages),
1626
+ };
1627
+ const data = signal(applyComputedValues(fields, initialValues), ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
1628
+ // The baseline `dirty` is measured against: the initialValues option until
1629
+ // reset(newValues) replaces it. Distinct from that option, which never
1630
+ // changes. See the React adapter for the full rationale.
1631
+ const baselineValues = signal({ ...data() }, ...(ngDevMode ? [{ debugName: "baselineValues" }] : /* istanbul ignore next */ []));
1632
+ const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
1633
+ const isDirty = signal(false, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
1634
+ const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
1635
+ const isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : /* istanbul ignore next */ []));
1636
+ const isSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitted" }] : /* istanbul ignore next */ []));
1637
+ const validationResult = signal(validateFields(fields, data(), undefined, validationContext), ...(ngDevMode ? [{ debugName: "validationResult" }] : /* istanbul ignore next */ []));
1638
+ const isValidating = signal(false, ...(ngDevMode ? [{ debugName: "isValidating" }] : /* istanbul ignore next */ []));
1639
+ let validationRun = 0;
1640
+ let validationController;
1641
+ // A submit gets its own run counter and controller. Typing aborts the live
1642
+ // validation run, and a submit must not be collateral damage of that.
1643
+ let submitRun = 0;
1644
+ let submitController;
1645
+ const isValid = computed(() => validationResult().valid, ...(ngDevMode ? [{ debugName: "isValid" }] : /* istanbul ignore next */ []));
1646
+ const isValidationComplete = computed(() => validationResult().complete && !isValidating(), ...(ngDevMode ? [{ debugName: "isValidationComplete" }] : /* istanbul ignore next */ []));
1647
+ const validationStatus = computed(() => isValidating() ? 'pending' : validationResult().status, ...(ngDevMode ? [{ debugName: "validationStatus" }] : /* istanbul ignore next */ []));
1648
+ function commitSyncResult(res) {
1649
+ validationResult.set(res);
1650
+ return res.valid;
1651
+ }
1205
1652
  function validate() {
1206
- const res = validateFields(fields, data());
1653
+ const res = validateFields(fields, data(), undefined, validationContext);
1207
1654
  errors.set(res.errors);
1208
- return res.valid;
1655
+ return commitSyncResult(res);
1656
+ }
1657
+ async function validateAsync() {
1658
+ const run = ++validationRun;
1659
+ validationController?.abort();
1660
+ const controller = new AbortController();
1661
+ validationController = controller;
1662
+ const snapshot = data();
1663
+ isValidating.set(true);
1664
+ try {
1665
+ const res = await validateFieldsAsync(fields, snapshot, snapshot, {
1666
+ ...validationContext,
1667
+ signal: controller.signal,
1668
+ });
1669
+ if (run !== validationRun || data() !== snapshot) {
1670
+ return res.valid;
1671
+ }
1672
+ errors.set(res.errors);
1673
+ validationResult.set(res);
1674
+ return res.valid;
1675
+ }
1676
+ finally {
1677
+ if (run === validationRun) {
1678
+ isValidating.set(false);
1679
+ }
1680
+ }
1209
1681
  }
1210
1682
  function handleChange(newData) {
1211
1683
  const next = applyComputedValues(fields, newData);
1212
1684
  data.set(next);
1213
1685
  isDirty.set(true);
1686
+ validationController?.abort();
1687
+ validationRun += 1;
1688
+ isValidating.set(false);
1689
+ const res = validateFields(fields, next, undefined, validationContext);
1690
+ commitSyncResult(res);
1214
1691
  if (validateOnChange) {
1215
- const res = validateFields(fields, next);
1216
1692
  errors.set(res.errors);
1217
1693
  }
1218
1694
  }
@@ -1222,22 +1698,51 @@ function createDynamicFormStore(options) {
1222
1698
  function setFieldTouched(name, isTouched = true) {
1223
1699
  touched.set({ ...touched(), [name]: isTouched });
1224
1700
  }
1701
+ /**
1702
+ * Marks every field touched at once. `handleSubmit` calls this for you, so
1703
+ * an invalid submit surfaces errors on fields the user never focused - bind
1704
+ * `[touched]` on `<dfk-multi-field-input>` for it to take effect.
1705
+ */
1706
+ function touchAll() {
1707
+ touched.set(Object.fromEntries(collectFieldPaths(fields, data()).map((path) => [path, true])));
1708
+ }
1709
+ /** Clears the touched map without touching data, errors or dirty state. */
1710
+ function getDirtyValues() {
1711
+ const baseline = baselineValues();
1712
+ const current = data();
1713
+ const dirty = {};
1714
+ for (const key of Object.keys(current)) {
1715
+ if (!Object.is(current[key], baseline[key])) {
1716
+ dirty[key] = current[key];
1717
+ }
1718
+ }
1719
+ return dirty;
1720
+ }
1721
+ function resetTouched() {
1722
+ touched.set({});
1723
+ }
1225
1724
  function handleBlur(fieldName) {
1226
1725
  setFieldTouched(fieldName, true);
1227
1726
  if (validateOnBlur) {
1228
- const res = validateFields(fields, data());
1727
+ const res = validateFields(fields, data(), undefined, validationContext);
1229
1728
  errors.set(res.errors);
1729
+ commitSyncResult(res);
1230
1730
  }
1231
1731
  }
1232
1732
  function reset(newValues) {
1233
1733
  const seed = newValues ?? initialValues;
1234
1734
  const next = applyComputedValues(fields, seed);
1235
1735
  data.set(next);
1736
+ baselineValues.set({ ...next });
1236
1737
  errors.set({});
1237
1738
  isDirty.set(false);
1238
1739
  touched.set({});
1239
1740
  isSubmitting.set(false);
1240
1741
  isSubmitted.set(false);
1742
+ validationController?.abort();
1743
+ validationRun += 1;
1744
+ isValidating.set(false);
1745
+ commitSyncResult(validateFields(fields, next, undefined, validationContext));
1241
1746
  }
1242
1747
  /**
1243
1748
  * Returns a submit handler, mirroring the React and Vue `useDynamicForm`
@@ -1250,18 +1755,54 @@ function createDynamicFormStore(options) {
1250
1755
  e.preventDefault();
1251
1756
  }
1252
1757
  isSubmitting.set(true);
1758
+ const thisSubmit = ++submitRun;
1253
1759
  try {
1254
- const res = validateFields(fields, data());
1255
- errors.set(res.errors);
1760
+ // Touch everything before validating: a submit is the user asserting
1761
+ // the form is finished, so a field they never focused should still
1762
+ // show its error. Without this, submitting an untouched form appears
1763
+ // to do nothing at all.
1764
+ touchAll();
1765
+ const run = ++validationRun;
1766
+ // Cancel any live run so its (older) result cannot land on top of this
1767
+ // one, but validate under a controller of the submit's own.
1768
+ validationController?.abort();
1769
+ submitController?.abort();
1770
+ const controller = new AbortController();
1771
+ submitController = controller;
1772
+ const snapshot = data();
1773
+ isValidating.set(true);
1774
+ const res = await validateFieldsAsync(fields, snapshot, snapshot, {
1775
+ ...validationContext,
1776
+ signal: controller.signal,
1777
+ });
1778
+ if (thisSubmit !== submitRun) {
1779
+ return;
1780
+ }
1781
+ // Editing during the submit does not cancel it - the user submitted
1782
+ // this snapshot and is owed an answer for it. What the form *shows*
1783
+ // still has to describe the data on screen, so when it moved on, the
1784
+ // displayed state is re-derived instead of showing the old pass.
1785
+ if (data() === snapshot && run === validationRun) {
1786
+ errors.set(res.errors);
1787
+ validationResult.set(res);
1788
+ }
1789
+ else {
1790
+ const live = validateFields(fields, data(), undefined, validationContext);
1791
+ errors.set(live.errors);
1792
+ validationResult.set(live);
1793
+ }
1256
1794
  isSubmitted.set(true);
1257
1795
  if (res.valid) {
1258
- await onValid(data());
1796
+ await onValid(snapshot);
1259
1797
  }
1260
1798
  else if (onInvalid) {
1261
1799
  onInvalid(res.errors);
1262
1800
  }
1263
1801
  }
1264
1802
  finally {
1803
+ if (thisSubmit === submitRun) {
1804
+ isValidating.set(false);
1805
+ }
1265
1806
  isSubmitting.set(false);
1266
1807
  }
1267
1808
  };
@@ -1270,16 +1811,24 @@ function createDynamicFormStore(options) {
1270
1811
  data,
1271
1812
  errors,
1272
1813
  isValid,
1814
+ isValidating,
1815
+ isValidationComplete,
1816
+ validationStatus,
1273
1817
  isDirty,
1818
+ baselineValues,
1819
+ getDirtyValues,
1274
1820
  touched,
1275
1821
  isSubmitting,
1276
1822
  isSubmitted,
1277
1823
  setFieldValue,
1278
1824
  setFieldTouched,
1825
+ touchAll,
1826
+ resetTouched,
1279
1827
  handleChange,
1280
1828
  handleBlur,
1281
1829
  reset,
1282
1830
  validate,
1831
+ validateAsync,
1283
1832
  handleSubmit,
1284
1833
  };
1285
1834
  }
@@ -1304,8 +1853,8 @@ class ColumnLayout {
1304
1853
  get gap() {
1305
1854
  return this.config?.gap ?? 12;
1306
1855
  }
1307
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: ColumnLayout, deps: [], target: i0.ɵɵFactoryTarget.Component });
1308
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: ColumnLayout, isStandalone: true, selector: "dfk-column-layout", inputs: { config: "config", template: "template" }, ngImport: i0, template: `<div
1856
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ColumnLayout, deps: [], target: i0.ɵɵFactoryTarget.Component });
1857
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: ColumnLayout, isStandalone: true, selector: "dfk-column-layout", inputs: { config: "config", template: "template" }, ngImport: i0, template: `<div
1309
1858
  [style.display]="'flex'"
1310
1859
  [style.flexDirection]="'column'"
1311
1860
  [style.gap]="gap + 'px'"
@@ -1313,7 +1862,7 @@ class ColumnLayout {
1313
1862
  <ng-container *ngTemplateOutlet="template"></ng-container>
1314
1863
  </div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
1315
1864
  }
1316
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: ColumnLayout, decorators: [{
1865
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ColumnLayout, decorators: [{
1317
1866
  type: Component,
1318
1867
  args: [{
1319
1868
  selector: 'dfk-column-layout',
@@ -1338,8 +1887,8 @@ class RowLayout {
1338
1887
  get gap() {
1339
1888
  return this.config?.gap ?? 12;
1340
1889
  }
1341
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: RowLayout, deps: [], target: i0.ɵɵFactoryTarget.Component });
1342
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: RowLayout, isStandalone: true, selector: "dfk-row-layout", inputs: { config: "config", template: "template" }, ngImport: i0, template: `<div
1890
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RowLayout, deps: [], target: i0.ɵɵFactoryTarget.Component });
1891
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: RowLayout, isStandalone: true, selector: "dfk-row-layout", inputs: { config: "config", template: "template" }, ngImport: i0, template: `<div
1343
1892
  [style.display]="'flex'"
1344
1893
  [style.flexDirection]="'row'"
1345
1894
  [style.gap]="gap + 'px'"
@@ -1347,7 +1896,7 @@ class RowLayout {
1347
1896
  <ng-container *ngTemplateOutlet="template"></ng-container>
1348
1897
  </div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
1349
1898
  }
1350
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: RowLayout, decorators: [{
1899
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RowLayout, decorators: [{
1351
1900
  type: Component,
1352
1901
  args: [{
1353
1902
  selector: 'dfk-row-layout',
@@ -1375,8 +1924,8 @@ class GridLayout {
1375
1924
  get gap() {
1376
1925
  return this.config?.gap ?? 12;
1377
1926
  }
1378
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: GridLayout, deps: [], target: i0.ɵɵFactoryTarget.Component });
1379
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: GridLayout, isStandalone: true, selector: "dfk-grid-layout", inputs: { config: "config", template: "template" }, ngImport: i0, template: `<div
1927
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: GridLayout, deps: [], target: i0.ɵɵFactoryTarget.Component });
1928
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: GridLayout, isStandalone: true, selector: "dfk-grid-layout", inputs: { config: "config", template: "template" }, ngImport: i0, template: `<div
1380
1929
  [style.display]="'grid'"
1381
1930
  [style.gridTemplateColumns]="'repeat(' + columns + ', 1fr)'"
1382
1931
  [style.gap]="gap + 'px'"
@@ -1384,7 +1933,7 @@ class GridLayout {
1384
1933
  <ng-container *ngTemplateOutlet="template"></ng-container>
1385
1934
  </div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
1386
1935
  }
1387
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: GridLayout, decorators: [{
1936
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: GridLayout, decorators: [{
1388
1937
  type: Component,
1389
1938
  args: [{
1390
1939
  selector: 'dfk-grid-layout',
@@ -1408,8 +1957,8 @@ layoutRegistry.register('row', RowLayout);
1408
1957
  layoutRegistry.register('grid', GridLayout);
1409
1958
 
1410
1959
  class DynamicFieldKitModule {
1411
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicFieldKitModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
1412
- static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.25", ngImport: i0, type: DynamicFieldKitModule, imports: [CommonModule,
1960
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldKitModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
1961
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldKitModule, imports: [CommonModule,
1413
1962
  DynamicInput,
1414
1963
  FieldInput,
1415
1964
  MultiFieldInput,
@@ -1421,7 +1970,7 @@ class DynamicFieldKitModule {
1421
1970
  ColumnLayout,
1422
1971
  RowLayout,
1423
1972
  GridLayout] });
1424
- static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicFieldKitModule, imports: [CommonModule,
1973
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldKitModule, imports: [CommonModule,
1425
1974
  DynamicInput,
1426
1975
  FieldInput,
1427
1976
  MultiFieldInput,
@@ -1429,7 +1978,7 @@ class DynamicFieldKitModule {
1429
1978
  RowLayout,
1430
1979
  GridLayout] });
1431
1980
  }
1432
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicFieldKitModule, decorators: [{
1981
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldKitModule, decorators: [{
1433
1982
  type: NgModule,
1434
1983
  args: [{
1435
1984
  imports: [