@dynamic-field-kit/angular 1.10.0 → 1.11.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @dynamic-field-kit/angular
2
2
 
3
+ ## 1.11.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ba3538b: Angular 22 is accepted: `@angular/core` and `@angular/common` are now `>=16 <23`. An app on Angular 22 could not install the package before, as npm refused the peer range. `scripts/verify-angular-peer-range.js` now checks Angular 16 and 22, and the demo app was built with the Angular 22 CLI and used in a browser.
8
+ - ba3538b: A registered field component that uses `OnPush` change detection now shows the props the form hands it after the first render. They were assigned to the component without marking it for check, so a value the form set itself, such as a computed field, did not show. From Angular 22 every component that names no strategy is `OnPush`, so on 22 this hit every registered field component.
9
+ - Angular 22 support.
10
+
11
+ ## 1.11.0
12
+
13
+ ### Minor Changes
14
+
15
+ - Input masks, titled sections and errors from the server; the built-in controls of the Vue and Angular adapters keep the focus and stay one element.
16
+ - b7ffb5c: Input masks. A field takes `mask`: a pattern such as `'(999) 999-9999'` (`9` a digit, `a` a letter, `*` either, anything else fixed) or a `{ format, parse }` pair. The renderer receives the formatted text as `value`, the form stores the unformatted value (`'5551234567'`), and validators, `computeValue`, drafts and `onSubmit` see that stored value. A separator appears with the character after it, a character that does not fit is neither stored nor left on screen, and Backspace on a separator removes the character before it. Renderers need no change. Core gains `createInputMask(pattern)` for the same formatting outside a form, the `InputMask` and `FieldMask` types, and `applyMask` / `parseInput` on `buildFieldRendererProps` for adapters; a mask takes effect when both core and the adapter are 1.11 or newer.
17
+ - 4326470: Sections. A field with `section: FieldDescription[]` is a titled section: `data[name]` is one object shaped by the nested fields, the non-repeating sibling of a group. Every adapter's `MultiFieldInput` renders it as a `<fieldset>` with `label` as its `<legend>`. Errors, pending rules and touched flags of the fields inside are keyed by path (`address.street`), `appearCondition` and `disabled` / `disabledCondition` on the section apply to all of it, and sections nest in groups and in each other. `validateFields`, `validateFieldsAsync` and `collectFieldPaths` look inside sections; `fieldsFromJsonSchema` now turns a nested object into a section instead of reporting it, and `fieldsToJsonSchema` writes a section as a nested object. Core gains `isFieldSection` and `sectionPathMap`. Sections need core and the adapter at 1.11 or newer.
18
+ - 5eaa3ca: Errors from the server. Every form store (`useDynamicForm` in React and Vue, `createDynamicForm` in Svelte, `createDynamicFormStore` in Angular) gains `setServerErrors(errors)`, `clearServerErrors(name?)` and `serverErrors`: what a backend refused is shown in `errors` after a field's own errors, its fields are marked touched, and an error stays until the value it was about changes, until it is cleared or the form is reset, or until the next submit. `isValid` keeps speaking for the form's own rules. Core gains `serverErrorsFrom(body, { paths })`, which reads the usual response shapes (a field map, `errors` as a map or a list, Zod's `issues` and flattened error, JSON Pointers) into that map, and `FORM_ERROR_KEY` for errors that name no field.
19
+
20
+ ### Patch Changes
21
+
22
+ - 673fce2: The built-in controls, used when no renderer is registered for a field type, are no longer drawn again on every change. Each change used to add a new input, textarea, select or checkbox to the page and leave the earlier ones there, so a form filled in with the built-in controls ended up with several copies of each field; the same happened to the HTML of a function renderer and to the "Unknown field type" message. A built-in control is now updated where it stands: it stays one element, keeps the focus while it is typed in, and a number that is still being typed (`1.50`) is not rewritten under the caret. Its `aria-describedby` is taken off again when the error it pointed at is gone.
23
+
3
24
  ## 1.10.0
4
25
 
5
26
  ### Minor Changes
package/README.md CHANGED
@@ -86,16 +86,23 @@ For a complete UI integration, see the
86
86
 
87
87
  ## Supported Angular versions
88
88
 
89
- The package declares `@angular/core` and `@angular/common` as `>=16 <22`,
89
+ The package declares `@angular/core` and `@angular/common` as `>=16 <23`,
90
90
  and `scripts/verify-angular-peer-range.js` proves both ends in CI by installing
91
- the packed tarballs against Angular 16 and 21 outside the workspace.
91
+ the packed tarballs against Angular 16 and 22 outside the workspace.
92
92
 
93
93
  The floor is 16 because the form store is built on `signal` and `computed`,
94
94
  which Angular introduced in 16. It read `>=14` until 1.6.0: npm accepted the
95
95
  install on 14 and 15 and the package then failed on import.
96
96
 
97
97
  The suite, the build and the demo app all run Angular 21, which is the version
98
- the setup below is written for.
98
+ the setup below is written for. The setup works unchanged on 22.
99
+
100
+ Angular 22 makes `OnPush` the default for a component that names no change
101
+ detection strategy, and that includes the field components you register. They
102
+ need nothing extra for it: the adapter marks a rendered field component for
103
+ check whenever it hands it new props. Up to 1.11.0 it did not, so on Angular 22
104
+ a value the form set itself, such as a computed field, did not show in a
105
+ registered component.
99
106
 
100
107
  ## Basic setup
101
108
 
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { EventEmitter, Output, Input, Component, InjectionToken, inject, ViewContainerRef, ViewChild, ChangeDetectionStrategy, HostListener, signal, computed, NgModule } from '@angular/core';
2
+ import { EventEmitter, Output, Input, Component, InjectionToken, inject, ChangeDetectorRef, 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
5
  import { fieldRegistry, makeErrorId, isAsyncOptions, createOptionsLoader, buildFieldRendererProps, makeFieldId, indexGroupPathMap, applyComputedValues, validateFields, canAddGroupItem, canRemoveGroupItem, createGroupItem, createMessageResolver, validateFieldsAsync, collectFieldPaths } from '@dynamic-field-kit/core';
@@ -162,6 +162,11 @@ class DynamicInput extends BaseInputComponent {
162
162
  host;
163
163
  compRef;
164
164
  inputInstance;
165
+ // The built-in control in use, the type it was built for and the options it lists. Kept so that a
166
+ // change updates the control where it stands.
167
+ builtIn;
168
+ builtInType;
169
+ builtInOptions;
165
170
  subscriptions = [];
166
171
  // Tracks which KNOWN_PROPS were actually supplied to DynamicInput (via
167
172
  // SimpleChanges), independent of TS class-field emit. See applyProps.
@@ -204,6 +209,12 @@ class DynamicInput extends BaseInputComponent {
204
209
  if (changes['extraProps']) {
205
210
  this.applyExtraProps(this.inputInstance);
206
211
  }
212
+ // The props above are assigned to the instance, which Angular does not
213
+ // see. Checking the host view alone skips an OnPush renderer that is not
214
+ // marked, and from Angular 22 every renderer that names no strategy is
215
+ // OnPush: a value the form set itself (a computed field, a reset) never
216
+ // showed. The renderer's own view is marked first.
217
+ this.compRef?.injector.get(ChangeDetectorRef).markForCheck();
207
218
  this.compRef?.changeDetectorRef?.detectChanges();
208
219
  }
209
220
  cleanup() {
@@ -219,6 +230,21 @@ class DynamicInput extends BaseInputComponent {
219
230
  this.compRef = undefined;
220
231
  this.inputInstance = undefined;
221
232
  }
233
+ /**
234
+ * Removes what this component put into the host element itself: a built-in
235
+ * control, the HTML of a function renderer, an error message. `host.clear()`
236
+ * does not: it only knows the components it created, which sit beside the
237
+ * host element, not inside it.
238
+ */
239
+ clearOwnNodes() {
240
+ const element = this.host.element.nativeElement;
241
+ while (element.firstChild) {
242
+ element.removeChild(element.firstChild);
243
+ }
244
+ this.builtIn = undefined;
245
+ this.builtInType = undefined;
246
+ this.builtInOptions = undefined;
247
+ }
222
248
  /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
223
249
  errorNodeId() {
224
250
  return makeErrorId(this.id ?? '');
@@ -241,8 +267,17 @@ class DynamicInput extends BaseInputComponent {
241
267
  }
242
268
  render() {
243
269
  const Renderer = this.registry.get(this.type);
270
+ // A built-in control of the same type is updated where it stands. Built
271
+ // again on every change, as it used to be, it would lose the focus after
272
+ // each character typed - and the ones built before stayed in the page.
273
+ if (!Renderer && this.builtIn && this.builtInType === this.type) {
274
+ this.syncBuiltIn(this.builtIn);
275
+ this.applyFallbackAria();
276
+ return;
277
+ }
244
278
  this.cleanup();
245
279
  this.host.clear();
280
+ this.clearOwnNodes();
246
281
  if (!Renderer) {
247
282
  if (this.renderDefaultFallbackHTML5(this.type)) {
248
283
  this.applyFallbackAria();
@@ -411,146 +446,155 @@ class DynamicInput extends BaseInputComponent {
411
446
  *
412
447
  * `render()` re-runs on every ngOnChanges while the fallback is in use (there
413
448
  * is no component instance to sync props into), so this stays current as the
414
- * field's error comes and goes.
449
+ * field's error comes and goes. The control is the same one from change to
450
+ * change, so a flag that is no longer given is taken off again.
415
451
  */
416
452
  applyFallbackAria() {
417
453
  const control = this.host.element.nativeElement.querySelector('input, select, textarea');
418
454
  if (!control) {
419
455
  return;
420
456
  }
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
- }
457
+ const flag = (name, value) => {
458
+ if (value === undefined) {
459
+ control.removeAttribute(name);
460
+ }
461
+ else {
462
+ control.setAttribute(name, value);
463
+ }
464
+ };
465
+ flag('aria-invalid', this.ariaInvalid === undefined ? undefined : String(this.ariaInvalid));
466
+ flag('aria-required', this.ariaRequired === undefined ? undefined : String(this.ariaRequired));
467
+ flag('aria-describedby', this.ariaDescribedBy || undefined);
430
468
  }
469
+ /**
470
+ * Builds the built-in control for `type` and puts it in the host element.
471
+ * What the control shows is set by `syncBuiltIn`, here and on every later
472
+ * change.
473
+ */
431
474
  renderDefaultFallbackHTML5(type) {
432
- const nativeEl = this.host.element.nativeElement;
475
+ const control = this.createBuiltIn(type);
476
+ if (!control) {
477
+ return false;
478
+ }
479
+ this.builtIn = control;
480
+ this.builtInType = type;
481
+ this.syncBuiltIn(control);
482
+ this.host.element.nativeElement.appendChild(control);
483
+ return true;
484
+ }
485
+ createBuiltIn(type) {
433
486
  if (type === 'text' ||
434
487
  type === 'password' ||
435
488
  type === 'email' ||
436
489
  type === 'number') {
437
490
  const input = document.createElement('input');
438
491
  input.type = type;
439
- if (this.id) {
440
- input.id = this.id;
441
- }
442
- if (this.className) {
443
- input.className = this.className;
444
- }
445
- if (this.placeholder) {
446
- input.placeholder = this.placeholder;
447
- }
448
- if (this.disabled) {
449
- input.disabled = true;
450
- }
451
- if (this.readOnly) {
452
- input.readOnly = true;
453
- }
454
- if (this.required) {
455
- input.required = true;
456
- }
457
- input.value = (this.value ?? '').toString();
458
- input.addEventListener('input', (e) => {
459
- const raw = e.target.value;
492
+ input.addEventListener('input', () => {
493
+ const raw = input.value;
460
494
  const val = type === 'number' ? (raw === '' ? undefined : Number(raw)) : raw;
461
495
  this.emitValue(val);
462
496
  });
463
- nativeEl.appendChild(input);
464
- return true;
497
+ return input;
465
498
  }
466
499
  if (type === 'textarea') {
467
500
  const textarea = document.createElement('textarea');
468
- if (this.id) {
469
- textarea.id = this.id;
470
- }
471
- if (this.className) {
472
- textarea.className = this.className;
473
- }
474
- if (this.placeholder) {
475
- textarea.placeholder = this.placeholder;
476
- }
477
- if (this.disabled) {
478
- textarea.disabled = true;
479
- }
480
- if (this.readOnly) {
481
- textarea.readOnly = true;
482
- }
483
- if (this.required) {
484
- textarea.required = true;
485
- }
486
- textarea.value = this.value ?? '';
487
- textarea.addEventListener('input', (e) => {
488
- this.emitValue(e.target.value);
501
+ textarea.addEventListener('input', () => {
502
+ this.emitValue(textarea.value);
489
503
  });
490
- nativeEl.appendChild(textarea);
491
- return true;
504
+ return textarea;
492
505
  }
493
506
  if (type === 'checkbox') {
494
507
  const checkbox = document.createElement('input');
495
508
  checkbox.type = 'checkbox';
496
- if (this.id) {
497
- checkbox.id = this.id;
498
- }
499
- if (this.className) {
500
- checkbox.className = this.className;
501
- }
502
- if (this.disabled || this.readOnly) {
503
- checkbox.disabled = true;
504
- }
505
- if (this.required) {
506
- checkbox.required = true;
507
- }
508
- checkbox.checked = Boolean(this.value);
509
- checkbox.addEventListener('change', (e) => {
510
- this.emitValue(e.target.checked);
509
+ checkbox.addEventListener('change', () => {
510
+ this.emitValue(checkbox.checked);
511
511
  });
512
- nativeEl.appendChild(checkbox);
513
- return true;
512
+ return checkbox;
514
513
  }
515
514
  if (type === 'select') {
516
515
  const select = document.createElement('select');
517
- if (this.id) {
518
- select.id = this.id;
519
- }
520
- if (this.className) {
521
- select.className = this.className;
516
+ select.addEventListener('change', () => {
517
+ this.emitValue(select.value);
518
+ });
519
+ return select;
520
+ }
521
+ return null;
522
+ }
523
+ /** Brings a built-in control in line with the current inputs. */
524
+ syncBuiltIn(control) {
525
+ const attribute = (name, value) => {
526
+ if (value) {
527
+ control.setAttribute(name, value);
522
528
  }
523
- if (this.disabled || this.readOnly) {
524
- select.disabled = true;
529
+ else {
530
+ control.removeAttribute(name);
525
531
  }
526
- if (this.required) {
527
- select.required = true;
532
+ };
533
+ attribute('id', this.id);
534
+ attribute('class', this.className);
535
+ control.required = Boolean(this.required);
536
+ if (control instanceof HTMLSelectElement) {
537
+ // a select has no read-only state of its own
538
+ control.disabled = Boolean(this.disabled || this.readOnly);
539
+ this.syncBuiltInOptions(control);
540
+ return;
541
+ }
542
+ if (control instanceof HTMLInputElement && control.type === 'checkbox') {
543
+ // nor has a checkbox
544
+ control.disabled = Boolean(this.disabled || this.readOnly);
545
+ control.checked = Boolean(this.value);
546
+ return;
547
+ }
548
+ attribute('placeholder', this.placeholder);
549
+ control.disabled = Boolean(this.disabled);
550
+ control.readOnly = Boolean(this.readOnly);
551
+ const next = (this.value ?? '').toString();
552
+ // Written only when it says something else than the control already
553
+ // does: writing the text a user is in the middle of typing moves the
554
+ // caret to its end. For a number, "the same" is the same number: "1.50"
555
+ // is 1.5, and must not be rewritten to "1.5" under the caret.
556
+ const same = control.type === 'number' && control.value !== '' && next !== ''
557
+ ? Number(control.value) === Number(next)
558
+ : control.value === next;
559
+ if (!same) {
560
+ control.value = next;
561
+ }
562
+ }
563
+ /** The options of a built-in select, rebuilt only when the list changed, and the one that is chosen. */
564
+ syncBuiltInOptions(select) {
565
+ const options = (this.options || []).map((opt) => {
566
+ const optObj = opt;
567
+ const optVal = optObj['value'] ?? optObj['id'] ?? opt;
568
+ const optLabel = optObj['label'] ?? optObj['name'] ?? String(optVal);
569
+ return { value: String(optVal), label: String(optLabel) };
570
+ });
571
+ const listed = JSON.stringify(options);
572
+ if (listed !== this.builtInOptions) {
573
+ this.builtInOptions = listed;
574
+ while (select.firstChild) {
575
+ select.removeChild(select.firstChild);
528
576
  }
529
577
  const defaultOpt = document.createElement('option');
530
578
  defaultOpt.value = '';
531
579
  defaultOpt.disabled = true;
532
580
  defaultOpt.textContent = '-- Select --';
533
581
  select.appendChild(defaultOpt);
534
- const options = this.options || [];
535
- options.forEach((opt) => {
536
- const optObj = opt;
537
- const optVal = optObj['value'] ?? optObj['id'] ?? opt;
538
- const optLabel = optObj['label'] ?? optObj['name'] ?? String(optVal);
582
+ for (const option of options) {
539
583
  const optionEl = document.createElement('option');
540
- optionEl.value = String(optVal);
541
- optionEl.textContent = String(optLabel);
542
- if (String(optVal) === String(this.value)) {
543
- optionEl.selected = true;
544
- }
584
+ optionEl.value = option.value;
585
+ optionEl.textContent = option.label;
545
586
  select.appendChild(optionEl);
546
- });
547
- select.addEventListener('change', (e) => {
548
- this.emitValue(e.target.value);
549
- });
550
- nativeEl.appendChild(select);
551
- return true;
587
+ }
588
+ }
589
+ const wanted = String(this.value);
590
+ const all = Array.from(select.options);
591
+ // Without a match the first option that can be chosen is shown, which is
592
+ // what a select built from scratch shows.
593
+ const chosen = all.find((option) => !option.disabled && option.value === wanted) ??
594
+ all.find((option) => !option.disabled);
595
+ if (chosen && !chosen.selected) {
596
+ chosen.selected = true;
552
597
  }
553
- return false;
554
598
  }
555
599
  renderError(message) {
556
600
  const el = document.createElement('div');
@@ -650,6 +694,33 @@ class FieldInput {
650
694
  onOptionsQuery = (query) => {
651
695
  this.loader?.setQuery(query);
652
696
  };
697
+ /**
698
+ * A masked field shows formatted text; `parseInput` turns what its renderer
699
+ * reports back into the value to store.
700
+ */
701
+ handleValueChange(input) {
702
+ const props = this.rendererProps;
703
+ const parseInput = props?.parseInput;
704
+ if (props &&
705
+ parseInput &&
706
+ typeof input === 'string' &&
707
+ input !== props.value) {
708
+ // The text on screen is no longer the text this field last handed down,
709
+ // and the text to show next may well be that same one: the mask takes
710
+ // nothing from a letter where a digit belongs, or from a character past
711
+ // its last place. A binding that does not change writes nothing, so the
712
+ // refused character would stay on screen. Tell the renderer what it
713
+ // holds now; whatever is shown next is then a change.
714
+ this.rendererProps = { ...props, value: input };
715
+ this.cdr.detectChanges();
716
+ this.rendererProps = props;
717
+ this.cdr.markForCheck();
718
+ }
719
+ this.onValueChangeField.emit({
720
+ value: parseInput ? parseInput(input) : input,
721
+ key: this.fieldDescription.name,
722
+ });
723
+ }
653
724
  ngOnChanges(_changes) {
654
725
  this.syncOptionsLoader();
655
726
  this.rendererProps = this.buildProps();
@@ -696,6 +767,7 @@ class FieldInput {
696
767
  touched: this.touched,
697
768
  dirty: this.dirty,
698
769
  optionsState: this.optionsState,
770
+ applyMask: true,
699
771
  });
700
772
  // Explicitly bound inputs override what the field description resolves to,
701
773
  // so a host can still drive options/disabled/error itself.
@@ -739,9 +811,7 @@ class FieldInput {
739
811
  [step]="p.step"
740
812
  [accept]="p.accept"
741
813
  [multiple]="p.multiple"
742
- (valueChange)="
743
- onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
744
- "
814
+ (valueChange)="handleValueChange($event)"
745
815
  (focusout)="onBlurField.emit(fieldDescription!.name)"
746
816
  [extraProps]="p.extraProps"
747
817
  [onOptionsQuery]="onOptionsQuery"
@@ -782,9 +852,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
782
852
  [step]="p.step"
783
853
  [accept]="p.accept"
784
854
  [multiple]="p.multiple"
785
- (valueChange)="
786
- onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
787
- "
855
+ (valueChange)="handleValueChange($event)"
788
856
  (focusout)="onBlurField.emit(fieldDescription!.name)"
789
857
  [extraProps]="p.extraProps"
790
858
  [onOptionsQuery]="onOptionsQuery"
@@ -821,6 +889,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
821
889
  type: Output
822
890
  }] } });
823
891
 
892
+ const NO_VALUES = Object.freeze({});
893
+ /**
894
+ * The fields of a titled section, or undefined for any other field. A field
895
+ * that also has `fields` is a repeatable group.
896
+ */
897
+ function sectionFieldsOf(field) {
898
+ return Array.isArray(field.section) && !Array.isArray(field.fields)
899
+ ? field.section
900
+ : undefined;
901
+ }
902
+ /** The object a section stores, or an empty one that keeps its identity. */
903
+ function sectionValuesOf(data, name) {
904
+ const value = data[name];
905
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
906
+ ? value
907
+ : NO_VALUES;
908
+ }
909
+ /**
910
+ * The entries of one section, keyed from inside it: `address.street` becomes
911
+ * `street`. An absent map stays absent, which a nested form reads as
912
+ * "uncontrolled".
913
+ */
914
+ function sectionPathMap(source, sectionName) {
915
+ if (source === undefined) {
916
+ return undefined;
917
+ }
918
+ const scoped = {};
919
+ const prefix = `${sectionName}.`;
920
+ for (const [path, value] of Object.entries(source)) {
921
+ if (path.startsWith(prefix) && path.length > prefix.length) {
922
+ scoped[path.slice(prefix.length)] = value;
923
+ }
924
+ }
925
+ return scoped;
926
+ }
927
+ /**
928
+ * Whether a section is disabled, by its flag or by its condition: the test
929
+ * validation applies before it skips the section.
930
+ */
931
+ function isSectionDisabled(field, data, rootData) {
932
+ return (field.disabled === true ||
933
+ field.disabledCondition?.(data, rootData) === true);
934
+ }
935
+
824
936
  const DEFAULT_BREAKPOINT = 768;
825
937
  // Per-instance id counter, so two forms rendering the same field name emit
826
938
  // different DOM ids.
@@ -886,6 +998,10 @@ class MultiFieldInput {
886
998
  indexedErrors = new Map();
887
999
  indexedTouchedSource;
888
1000
  indexedTouched = new Map();
1001
+ sectionErrorsSource;
1002
+ sectionErrors = new Map();
1003
+ sectionTouchedSource;
1004
+ sectionTouched = new Map();
889
1005
  get effectiveIdPrefix() {
890
1006
  return this.idPrefix ?? `dfk-${this.instanceId}`;
891
1007
  }
@@ -944,6 +1060,41 @@ class MultiFieldInput {
944
1060
  handleGroupBlur(fieldName, index, key) {
945
1061
  this.handleBlurField(`${fieldName}[${index}].${key}`);
946
1062
  }
1063
+ /** The fields of a titled section, or undefined for any other field. */
1064
+ sectionOf(field) {
1065
+ return sectionFieldsOf(field);
1066
+ }
1067
+ sectionValues(fieldName) {
1068
+ return sectionValuesOf(this.data, fieldName);
1069
+ }
1070
+ isSectionDisabled(field) {
1071
+ return isSectionDisabled(field, this.data, this.rootData ?? this.data);
1072
+ }
1073
+ // Cached per source map for the reason given at `touchedForItem`: a fresh
1074
+ // object in a template binding is a new identity on every pass.
1075
+ errorsForSection(fieldName) {
1076
+ if (this.sectionErrorsSource !== this.errors) {
1077
+ this.sectionErrorsSource = this.errors;
1078
+ this.sectionErrors.clear();
1079
+ }
1080
+ if (!this.sectionErrors.has(fieldName)) {
1081
+ this.sectionErrors.set(fieldName, sectionPathMap(this.errors, fieldName));
1082
+ }
1083
+ return this.sectionErrors.get(fieldName);
1084
+ }
1085
+ touchedForSection(fieldName) {
1086
+ const current = this.effectiveTouched;
1087
+ if (this.sectionTouchedSource !== current) {
1088
+ this.sectionTouchedSource = current;
1089
+ this.sectionTouched.clear();
1090
+ }
1091
+ let scoped = this.sectionTouched.get(fieldName);
1092
+ if (!scoped) {
1093
+ scoped = sectionPathMap(current, fieldName) ?? EMPTY_TOUCHED;
1094
+ this.sectionTouched.set(fieldName, scoped);
1095
+ }
1096
+ return scoped;
1097
+ }
947
1098
  /**
948
1099
  * Clears the internally tracked touched state. Only meaningful in
949
1100
  * uncontrolled mode - when `touched` is bound, resetting the form store
@@ -1125,7 +1276,7 @@ class MultiFieldInput {
1125
1276
  >
1126
1277
  <ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
1127
1278
  <dfk-field-input
1128
- *ngIf="!field.fields"
1279
+ *ngIf="!field.fields && !sectionOf(field)"
1129
1280
  [fieldDescription]="field"
1130
1281
  [data]="data"
1131
1282
  [rootData]="rootData"
@@ -1138,6 +1289,29 @@ class MultiFieldInput {
1138
1289
  (onBlurField)="handleBlurField($event)"
1139
1290
  ></dfk-field-input>
1140
1291
 
1292
+ <!--
1293
+ A titled section: one nested form over the object data[name], with
1294
+ the errors, touched flags and blur of its fields keyed
1295
+ name.childName. A fieldset names the group through its legend and
1296
+ disables every control inside it natively.
1297
+ -->
1298
+ <fieldset
1299
+ *ngIf="!field.fields && sectionOf(field) as section"
1300
+ [class]="field.className"
1301
+ [disabled]="isSectionDisabled(field)"
1302
+ >
1303
+ <legend *ngIf="field.label">{{ field.label }}</legend>
1304
+ <dfk-multi-field-input
1305
+ [fieldDescriptions]="section"
1306
+ [properties]="sectionValues(field.name)"
1307
+ [rootData]="rootData ?? data"
1308
+ [errors]="errorsForSection(field.name)"
1309
+ [touched]="touchedForSection(field.name)"
1310
+ (onBlurField)="handleBlurField(field.name + '.' + $event)"
1311
+ (onChange)="onFieldChange({ value: $event, key: field.name })"
1312
+ ></dfk-multi-field-input>
1313
+ </fieldset>
1314
+
1141
1315
  <div *ngIf="field.fields" [class]="field.className">
1142
1316
  <div *ngIf="field.label">{{ field.label }}</div>
1143
1317
  <div
@@ -1217,7 +1391,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1217
1391
  >
1218
1392
  <ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
1219
1393
  <dfk-field-input
1220
- *ngIf="!field.fields"
1394
+ *ngIf="!field.fields && !sectionOf(field)"
1221
1395
  [fieldDescription]="field"
1222
1396
  [data]="data"
1223
1397
  [rootData]="rootData"
@@ -1230,6 +1404,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1230
1404
  (onBlurField)="handleBlurField($event)"
1231
1405
  ></dfk-field-input>
1232
1406
 
1407
+ <!--
1408
+ A titled section: one nested form over the object data[name], with
1409
+ the errors, touched flags and blur of its fields keyed
1410
+ name.childName. A fieldset names the group through its legend and
1411
+ disables every control inside it natively.
1412
+ -->
1413
+ <fieldset
1414
+ *ngIf="!field.fields && sectionOf(field) as section"
1415
+ [class]="field.className"
1416
+ [disabled]="isSectionDisabled(field)"
1417
+ >
1418
+ <legend *ngIf="field.label">{{ field.label }}</legend>
1419
+ <dfk-multi-field-input
1420
+ [fieldDescriptions]="section"
1421
+ [properties]="sectionValues(field.name)"
1422
+ [rootData]="rootData ?? data"
1423
+ [errors]="errorsForSection(field.name)"
1424
+ [touched]="touchedForSection(field.name)"
1425
+ (onBlurField)="handleBlurField(field.name + '.' + $event)"
1426
+ (onChange)="onFieldChange({ value: $event, key: field.name })"
1427
+ ></dfk-multi-field-input>
1428
+ </fieldset>
1429
+
1233
1430
  <div *ngIf="field.fields" [class]="field.className">
1234
1431
  <div *ngIf="field.label">{{ field.label }}</div>
1235
1432
  <div
@@ -1616,6 +1813,58 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1616
1813
  type: Input
1617
1814
  }] } });
1618
1815
 
1816
+ function toFieldErrors(input) {
1817
+ const errors = {};
1818
+ for (const [path, value] of Object.entries(input)) {
1819
+ const messages = (typeof value === 'string' ? [value] : [...(value ?? [])])
1820
+ .filter((message) => typeof message === 'string')
1821
+ .map((message) => message.trim())
1822
+ .filter((message) => message !== '');
1823
+ if (messages.length > 0) {
1824
+ errors[path] = messages;
1825
+ }
1826
+ }
1827
+ return errors;
1828
+ }
1829
+ /** The form's own errors with the server's after them, field by field. */
1830
+ function mergeFieldErrors(own, server) {
1831
+ const paths = Object.keys(server);
1832
+ if (paths.length === 0) {
1833
+ return own;
1834
+ }
1835
+ const merged = { ...own };
1836
+ for (const path of paths) {
1837
+ merged[path] = [...(own[path] ?? []), ...server[path]];
1838
+ }
1839
+ return merged;
1840
+ }
1841
+ function valueAt(data, path) {
1842
+ let value = data;
1843
+ for (const segment of path.match(/[^.[\]]+/g) ?? []) {
1844
+ if (value === null || typeof value !== 'object') {
1845
+ return undefined;
1846
+ }
1847
+ value = value[segment];
1848
+ }
1849
+ return value;
1850
+ }
1851
+ /**
1852
+ * The server's errors without those of the fields whose value changed: the
1853
+ * server judged a value that is no longer the one in the field.
1854
+ */
1855
+ function withoutChangedFields(server, previous, next) {
1856
+ const paths = Object.keys(server);
1857
+ const kept = paths.filter((path) => Object.is(valueAt(previous, path), valueAt(next, path)));
1858
+ if (kept.length === paths.length) {
1859
+ return server;
1860
+ }
1861
+ return Object.fromEntries(kept.map((path) => [path, server[path]]));
1862
+ }
1863
+ /** The errors without those of one field. */
1864
+ function withoutField(errors, name) {
1865
+ return Object.fromEntries(Object.entries(errors).filter(([path]) => path !== name));
1866
+ }
1867
+
1619
1868
  function createDynamicFormStore(options) {
1620
1869
  const fields = options.fields;
1621
1870
  const initialValues = options.initialValues || {};
@@ -1630,6 +1879,22 @@ function createDynamicFormStore(options) {
1630
1879
  // changes. See the React adapter for the full rationale.
1631
1880
  const baselineValues = signal({ ...data() }, ...(ngDevMode ? [{ debugName: "baselineValues" }] : /* istanbul ignore next */ []));
1632
1881
  const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
1882
+ // `errors` stays the writable signal it was, holding what is shown: the
1883
+ // form's own errors with the server's after them. The two parts are kept
1884
+ // apart here so that either can change without losing the other.
1885
+ let ownErrors = {};
1886
+ const serverErrors = signal({}, ...(ngDevMode ? [{ debugName: "serverErrors" }] : /* istanbul ignore next */ []));
1887
+ function showErrors(own) {
1888
+ ownErrors = own;
1889
+ errors.set(mergeFieldErrors(own, serverErrors()));
1890
+ }
1891
+ function showServerErrors(server) {
1892
+ if (server === serverErrors()) {
1893
+ return;
1894
+ }
1895
+ serverErrors.set(server);
1896
+ errors.set(mergeFieldErrors(ownErrors, server));
1897
+ }
1633
1898
  const isDirty = signal(false, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
1634
1899
  const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
1635
1900
  const isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : /* istanbul ignore next */ []));
@@ -1651,7 +1916,7 @@ function createDynamicFormStore(options) {
1651
1916
  }
1652
1917
  function validate() {
1653
1918
  const res = validateFields(fields, data(), undefined, validationContext);
1654
- errors.set(res.errors);
1919
+ showErrors(res.errors);
1655
1920
  return commitSyncResult(res);
1656
1921
  }
1657
1922
  async function validateAsync() {
@@ -1669,7 +1934,7 @@ function createDynamicFormStore(options) {
1669
1934
  if (run !== validationRun || data() !== snapshot) {
1670
1935
  return res.valid;
1671
1936
  }
1672
- errors.set(res.errors);
1937
+ showErrors(res.errors);
1673
1938
  validationResult.set(res);
1674
1939
  return res.valid;
1675
1940
  }
@@ -1681,6 +1946,8 @@ function createDynamicFormStore(options) {
1681
1946
  }
1682
1947
  function handleChange(newData) {
1683
1948
  const next = applyComputedValues(fields, newData);
1949
+ // The server judged the old value: its verdict goes with it.
1950
+ showServerErrors(withoutChangedFields(serverErrors(), data(), next));
1684
1951
  data.set(next);
1685
1952
  isDirty.set(true);
1686
1953
  validationController?.abort();
@@ -1689,7 +1956,7 @@ function createDynamicFormStore(options) {
1689
1956
  const res = validateFields(fields, next, undefined, validationContext);
1690
1957
  commitSyncResult(res);
1691
1958
  if (validateOnChange) {
1692
- errors.set(res.errors);
1959
+ showErrors(res.errors);
1693
1960
  }
1694
1961
  }
1695
1962
  function setFieldValue(name, value) {
@@ -1721,11 +1988,36 @@ function createDynamicFormStore(options) {
1721
1988
  function resetTouched() {
1722
1989
  touched.set({});
1723
1990
  }
1991
+ /**
1992
+ * Shows the errors a backend answered with, and marks their fields touched
1993
+ * so they are displayed. An error stays until the value it was about
1994
+ * changes, until `clearServerErrors` or `reset`, or until the next submit.
1995
+ * `isValid` is not affected: it speaks for the form's own rules. Core's
1996
+ * `serverErrorsFrom(body)` turns the usual response shapes into this map.
1997
+ */
1998
+ function setServerErrors(input) {
1999
+ const next = toFieldErrors(input);
2000
+ showServerErrors(next);
2001
+ touched.set({
2002
+ ...touched(),
2003
+ ...Object.fromEntries(Object.keys(next).map((path) => [path, true])),
2004
+ });
2005
+ }
2006
+ /** Removes the server errors of one field, or all of them. */
2007
+ function clearServerErrors(name) {
2008
+ if (name === undefined) {
2009
+ showServerErrors({});
2010
+ return;
2011
+ }
2012
+ if (name in serverErrors()) {
2013
+ showServerErrors(withoutField(serverErrors(), name));
2014
+ }
2015
+ }
1724
2016
  function handleBlur(fieldName) {
1725
2017
  setFieldTouched(fieldName, true);
1726
2018
  if (validateOnBlur) {
1727
2019
  const res = validateFields(fields, data(), undefined, validationContext);
1728
- errors.set(res.errors);
2020
+ showErrors(res.errors);
1729
2021
  commitSyncResult(res);
1730
2022
  }
1731
2023
  }
@@ -1734,7 +2026,8 @@ function createDynamicFormStore(options) {
1734
2026
  const next = applyComputedValues(fields, seed);
1735
2027
  data.set(next);
1736
2028
  baselineValues.set({ ...next });
1737
- errors.set({});
2029
+ serverErrors.set({});
2030
+ showErrors({});
1738
2031
  isDirty.set(false);
1739
2032
  touched.set({});
1740
2033
  isSubmitting.set(false);
@@ -1755,6 +2048,8 @@ function createDynamicFormStore(options) {
1755
2048
  e.preventDefault();
1756
2049
  }
1757
2050
  isSubmitting.set(true);
2051
+ // A new attempt: what the server said about the last one is history.
2052
+ showServerErrors({});
1758
2053
  const thisSubmit = ++submitRun;
1759
2054
  try {
1760
2055
  // Touch everything before validating: a submit is the user asserting
@@ -1783,12 +2078,12 @@ function createDynamicFormStore(options) {
1783
2078
  // still has to describe the data on screen, so when it moved on, the
1784
2079
  // displayed state is re-derived instead of showing the old pass.
1785
2080
  if (data() === snapshot && run === validationRun) {
1786
- errors.set(res.errors);
2081
+ showErrors(res.errors);
1787
2082
  validationResult.set(res);
1788
2083
  }
1789
2084
  else {
1790
2085
  const live = validateFields(fields, data(), undefined, validationContext);
1791
- errors.set(live.errors);
2086
+ showErrors(live.errors);
1792
2087
  validationResult.set(live);
1793
2088
  }
1794
2089
  isSubmitted.set(true);
@@ -1829,6 +2124,10 @@ function createDynamicFormStore(options) {
1829
2124
  reset,
1830
2125
  validate,
1831
2126
  validateAsync,
2127
+ /** What the server refused, by field path; already part of `errors`. */
2128
+ serverErrors: serverErrors.asReadonly(),
2129
+ setServerErrors,
2130
+ clearServerErrors,
1832
2131
  handleSubmit,
1833
2132
  };
1834
2133
  }
@@ -81,6 +81,9 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
81
81
  host: ViewContainerRef;
82
82
  private compRef?;
83
83
  private inputInstance?;
84
+ private builtIn?;
85
+ private builtInType?;
86
+ private builtInOptions?;
84
87
  private subscriptions;
85
88
  private supplied;
86
89
  ngOnChanges(changes: SimpleChanges): void;
@@ -90,6 +93,13 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
90
93
  private cleanup;
91
94
  private cleanupSubscriptions;
92
95
  private cleanupRenderedComponent;
96
+ /**
97
+ * Removes what this component put into the host element itself: a built-in
98
+ * control, the HTML of a function renderer, an error message. `host.clear()`
99
+ * does not: it only knows the components it created, which sit beside the
100
+ * host element, not inside it.
101
+ */
102
+ private clearOwnNodes;
93
103
  /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
94
104
  errorNodeId(): string;
95
105
  /** `error` may arrive as a bare string, so index 0 would be a character. */
@@ -132,10 +142,21 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
132
142
  *
133
143
  * `render()` re-runs on every ngOnChanges while the fallback is in use (there
134
144
  * is no component instance to sync props into), so this stays current as the
135
- * field's error comes and goes.
145
+ * field's error comes and goes. The control is the same one from change to
146
+ * change, so a flag that is no longer given is taken off again.
136
147
  */
137
148
  private applyFallbackAria;
149
+ /**
150
+ * Builds the built-in control for `type` and puts it in the host element.
151
+ * What the control shows is set by `syncBuiltIn`, here and on every later
152
+ * change.
153
+ */
138
154
  private renderDefaultFallbackHTML5;
155
+ private createBuiltIn;
156
+ /** Brings a built-in control in line with the current inputs. */
157
+ private syncBuiltIn;
158
+ /** The options of a built-in select, rebuilt only when the list changed, and the one that is chosen. */
159
+ private syncBuiltInOptions;
139
160
  private renderError;
140
161
  static ɵfac: i0.ɵɵFactoryDeclaration<DynamicInput, never>;
141
162
  static ɵcmp: i0.ɵɵComponentDeclaration<DynamicInput, "dfk-dynamic-input", never, { "type": { "alias": "type"; "required": false; }; "extraProps": { "alias": "extraProps"; "required": false; }; }, { "valueChange": "valueChange"; "onChange": "onChange"; }, never, never, true, never>;
@@ -188,6 +209,11 @@ declare class FieldInput implements OnChanges, OnDestroy {
188
209
  private optionsState?;
189
210
  /** Bound into the template so a renderer can drive a search-remote refetch. */
190
211
  onOptionsQuery: (query: string) => void;
212
+ /**
213
+ * A masked field shows formatted text; `parseInput` turns what its renderer
214
+ * reports back into the value to store.
215
+ */
216
+ handleValueChange(input: unknown): void;
191
217
  ngOnChanges(_changes: SimpleChanges): void;
192
218
  ngOnDestroy(): void;
193
219
  private syncOptionsLoader;
@@ -246,6 +272,10 @@ declare class MultiFieldInput implements OnInit, OnChanges {
246
272
  private indexedErrors;
247
273
  private indexedTouchedSource?;
248
274
  private indexedTouched;
275
+ private sectionErrorsSource?;
276
+ private sectionErrors;
277
+ private sectionTouchedSource?;
278
+ private sectionTouched;
249
279
  get effectiveIdPrefix(): string;
250
280
  /** The touched map in effect: the controlled input, else the internal one. */
251
281
  private get effectiveTouched();
@@ -258,6 +288,12 @@ declare class MultiFieldInput implements OnInit, OnChanges {
258
288
  errorsForItem(fieldName: string, index: number): Record<string, string[]> | undefined;
259
289
  touchedForItem(fieldName: string, index: number): Record<string, boolean>;
260
290
  handleGroupBlur(fieldName: string, index: number, key: string): void;
291
+ /** The fields of a titled section, or undefined for any other field. */
292
+ sectionOf(field: FieldDescription): FieldDescription[] | undefined;
293
+ sectionValues(fieldName: string): Properties;
294
+ isSectionDisabled(field: FieldDescription): boolean;
295
+ errorsForSection(fieldName: string): Record<string, string[]> | undefined;
296
+ touchedForSection(fieldName: string): Record<string, boolean>;
261
297
  /**
262
298
  * Clears the internally tracked touched state. Only meaningful in
263
299
  * uncontrolled mode - when `touched` is bound, resetting the form store
@@ -316,6 +352,10 @@ declare class DynamicFormDevToolsComponent {
316
352
  static ɵcmp: i0.ɵɵComponentDeclaration<DynamicFormDevToolsComponent, "dfk-dev-tools", never, { "data": { "alias": "data"; "required": false; }; "errors": { "alias": "errors"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "isDirty": { "alias": "isDirty"; "required": false; }; "fields": { "alias": "fields"; "required": false; }; }, {}, never, never, true, never>;
317
353
  }
318
354
 
355
+ /** What `setServerErrors` takes: a message or messages by field path. */
356
+ type ServerErrorsInput = Record<string, string | readonly string[] | null | undefined>;
357
+ type FieldErrors = Record<string, string[]>;
358
+
319
359
  interface DynamicFormOptions {
320
360
  fields: FieldDescription[];
321
361
  initialValues?: Properties;
@@ -351,6 +391,10 @@ declare function createDynamicFormStore(options: DynamicFormOptions): {
351
391
  reset: (newValues?: Properties) => void;
352
392
  validate: () => boolean;
353
393
  validateAsync: () => Promise<boolean>;
394
+ /** What the server refused, by field path; already part of `errors`. */
395
+ serverErrors: i0.Signal<FieldErrors>;
396
+ setServerErrors: (input: ServerErrorsInput) => void;
397
+ clearServerErrors: (name?: string) => void;
354
398
  handleSubmit: (onValid: (data: Properties) => void | Promise<void>, onInvalid?: (errors: Record<string, string[]>) => void) => (e?: Event) => Promise<void>;
355
399
  };
356
400
 
@@ -401,4 +445,4 @@ declare class DynamicFieldKitModule {
401
445
  declare const FIELD_REGISTRY: InjectionToken<FieldRegistry>;
402
446
 
403
447
  export { BaseInputComponent, ColumnLayout, DynamicFieldKitModule, DynamicFormDevToolsComponent, DynamicInput, FIELD_REGISTRY, FieldInput, GridLayout, LayoutRegistry, MultiFieldInput, RowLayout, createDynamicFormStore, layoutRegistry };
404
- export type { BaseLayoutConfig, DynamicFormOptions, FieldInputProps, ResponsiveLayoutConfig };
448
+ export type { BaseLayoutConfig, DynamicFormOptions, FieldInputProps, ResponsiveLayoutConfig, ServerErrorsInput };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamic-field-kit/angular",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "Angular renderer for dynamic-field-kit",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -27,8 +27,8 @@
27
27
  "CHANGELOG.md"
28
28
  ],
29
29
  "peerDependencies": {
30
- "@angular/common": ">=16 <22",
31
- "@angular/core": ">=16 <22",
30
+ "@angular/common": ">=16 <23",
31
+ "@angular/core": ">=16 <23",
32
32
  "@dynamic-field-kit/core": "^1.3.0"
33
33
  },
34
34
  "publishConfig": {
@@ -44,8 +44,8 @@
44
44
  "lint": "eslint ."
45
45
  },
46
46
  "devDependencies": {
47
- "@analogjs/vite-plugin-angular": "^2.7.1",
48
- "@analogjs/vitest-angular": "^2.7.1",
47
+ "@analogjs/vite-plugin-angular": "^2.8.0",
48
+ "@analogjs/vitest-angular": "^2.8.0",
49
49
  "@angular-devkit/build-angular": "^21.2.0",
50
50
  "@angular/cdk": "^21.2.14",
51
51
  "@angular/common": "^21.2.0",
@@ -54,7 +54,7 @@
54
54
  "@angular/core": "^21.2.0",
55
55
  "@angular/forms": "^21.2.0",
56
56
  "@angular/platform-browser": "^21.2.0",
57
- "@dynamic-field-kit/core": "^1.10.0",
57
+ "@dynamic-field-kit/core": "^1.11.0",
58
58
  "@vitest/coverage-istanbul": "^4.1.11",
59
59
  "jsdom": "^29.1.1",
60
60
  "ng-packagr": "^21.2.0",