@dynamic-field-kit/angular 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @dynamic-field-kit/angular
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+
12
+ ### Patch Changes
13
+
14
+ - 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.
15
+
3
16
  ## 1.10.0
4
17
 
5
18
  ### Minor Changes
@@ -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.
@@ -219,6 +224,21 @@ class DynamicInput extends BaseInputComponent {
219
224
  this.compRef = undefined;
220
225
  this.inputInstance = undefined;
221
226
  }
227
+ /**
228
+ * Removes what this component put into the host element itself: a built-in
229
+ * control, the HTML of a function renderer, an error message. `host.clear()`
230
+ * does not: it only knows the components it created, which sit beside the
231
+ * host element, not inside it.
232
+ */
233
+ clearOwnNodes() {
234
+ const element = this.host.element.nativeElement;
235
+ while (element.firstChild) {
236
+ element.removeChild(element.firstChild);
237
+ }
238
+ this.builtIn = undefined;
239
+ this.builtInType = undefined;
240
+ this.builtInOptions = undefined;
241
+ }
222
242
  /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
223
243
  errorNodeId() {
224
244
  return makeErrorId(this.id ?? '');
@@ -241,8 +261,17 @@ class DynamicInput extends BaseInputComponent {
241
261
  }
242
262
  render() {
243
263
  const Renderer = this.registry.get(this.type);
264
+ // A built-in control of the same type is updated where it stands. Built
265
+ // again on every change, as it used to be, it would lose the focus after
266
+ // each character typed - and the ones built before stayed in the page.
267
+ if (!Renderer && this.builtIn && this.builtInType === this.type) {
268
+ this.syncBuiltIn(this.builtIn);
269
+ this.applyFallbackAria();
270
+ return;
271
+ }
244
272
  this.cleanup();
245
273
  this.host.clear();
274
+ this.clearOwnNodes();
246
275
  if (!Renderer) {
247
276
  if (this.renderDefaultFallbackHTML5(this.type)) {
248
277
  this.applyFallbackAria();
@@ -411,146 +440,155 @@ class DynamicInput extends BaseInputComponent {
411
440
  *
412
441
  * `render()` re-runs on every ngOnChanges while the fallback is in use (there
413
442
  * is no component instance to sync props into), so this stays current as the
414
- * field's error comes and goes.
443
+ * field's error comes and goes. The control is the same one from change to
444
+ * change, so a flag that is no longer given is taken off again.
415
445
  */
416
446
  applyFallbackAria() {
417
447
  const control = this.host.element.nativeElement.querySelector('input, select, textarea');
418
448
  if (!control) {
419
449
  return;
420
450
  }
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
- }
451
+ const flag = (name, value) => {
452
+ if (value === undefined) {
453
+ control.removeAttribute(name);
454
+ }
455
+ else {
456
+ control.setAttribute(name, value);
457
+ }
458
+ };
459
+ flag('aria-invalid', this.ariaInvalid === undefined ? undefined : String(this.ariaInvalid));
460
+ flag('aria-required', this.ariaRequired === undefined ? undefined : String(this.ariaRequired));
461
+ flag('aria-describedby', this.ariaDescribedBy || undefined);
430
462
  }
463
+ /**
464
+ * Builds the built-in control for `type` and puts it in the host element.
465
+ * What the control shows is set by `syncBuiltIn`, here and on every later
466
+ * change.
467
+ */
431
468
  renderDefaultFallbackHTML5(type) {
432
- const nativeEl = this.host.element.nativeElement;
469
+ const control = this.createBuiltIn(type);
470
+ if (!control) {
471
+ return false;
472
+ }
473
+ this.builtIn = control;
474
+ this.builtInType = type;
475
+ this.syncBuiltIn(control);
476
+ this.host.element.nativeElement.appendChild(control);
477
+ return true;
478
+ }
479
+ createBuiltIn(type) {
433
480
  if (type === 'text' ||
434
481
  type === 'password' ||
435
482
  type === 'email' ||
436
483
  type === 'number') {
437
484
  const input = document.createElement('input');
438
485
  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;
486
+ input.addEventListener('input', () => {
487
+ const raw = input.value;
460
488
  const val = type === 'number' ? (raw === '' ? undefined : Number(raw)) : raw;
461
489
  this.emitValue(val);
462
490
  });
463
- nativeEl.appendChild(input);
464
- return true;
491
+ return input;
465
492
  }
466
493
  if (type === 'textarea') {
467
494
  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);
495
+ textarea.addEventListener('input', () => {
496
+ this.emitValue(textarea.value);
489
497
  });
490
- nativeEl.appendChild(textarea);
491
- return true;
498
+ return textarea;
492
499
  }
493
500
  if (type === 'checkbox') {
494
501
  const checkbox = document.createElement('input');
495
502
  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);
503
+ checkbox.addEventListener('change', () => {
504
+ this.emitValue(checkbox.checked);
511
505
  });
512
- nativeEl.appendChild(checkbox);
513
- return true;
506
+ return checkbox;
514
507
  }
515
508
  if (type === 'select') {
516
509
  const select = document.createElement('select');
517
- if (this.id) {
518
- select.id = this.id;
519
- }
520
- if (this.className) {
521
- select.className = this.className;
510
+ select.addEventListener('change', () => {
511
+ this.emitValue(select.value);
512
+ });
513
+ return select;
514
+ }
515
+ return null;
516
+ }
517
+ /** Brings a built-in control in line with the current inputs. */
518
+ syncBuiltIn(control) {
519
+ const attribute = (name, value) => {
520
+ if (value) {
521
+ control.setAttribute(name, value);
522
522
  }
523
- if (this.disabled || this.readOnly) {
524
- select.disabled = true;
523
+ else {
524
+ control.removeAttribute(name);
525
525
  }
526
- if (this.required) {
527
- select.required = true;
526
+ };
527
+ attribute('id', this.id);
528
+ attribute('class', this.className);
529
+ control.required = Boolean(this.required);
530
+ if (control instanceof HTMLSelectElement) {
531
+ // a select has no read-only state of its own
532
+ control.disabled = Boolean(this.disabled || this.readOnly);
533
+ this.syncBuiltInOptions(control);
534
+ return;
535
+ }
536
+ if (control instanceof HTMLInputElement && control.type === 'checkbox') {
537
+ // nor has a checkbox
538
+ control.disabled = Boolean(this.disabled || this.readOnly);
539
+ control.checked = Boolean(this.value);
540
+ return;
541
+ }
542
+ attribute('placeholder', this.placeholder);
543
+ control.disabled = Boolean(this.disabled);
544
+ control.readOnly = Boolean(this.readOnly);
545
+ const next = (this.value ?? '').toString();
546
+ // Written only when it says something else than the control already
547
+ // does: writing the text a user is in the middle of typing moves the
548
+ // caret to its end. For a number, "the same" is the same number: "1.50"
549
+ // is 1.5, and must not be rewritten to "1.5" under the caret.
550
+ const same = control.type === 'number' && control.value !== '' && next !== ''
551
+ ? Number(control.value) === Number(next)
552
+ : control.value === next;
553
+ if (!same) {
554
+ control.value = next;
555
+ }
556
+ }
557
+ /** The options of a built-in select, rebuilt only when the list changed, and the one that is chosen. */
558
+ syncBuiltInOptions(select) {
559
+ const options = (this.options || []).map((opt) => {
560
+ const optObj = opt;
561
+ const optVal = optObj['value'] ?? optObj['id'] ?? opt;
562
+ const optLabel = optObj['label'] ?? optObj['name'] ?? String(optVal);
563
+ return { value: String(optVal), label: String(optLabel) };
564
+ });
565
+ const listed = JSON.stringify(options);
566
+ if (listed !== this.builtInOptions) {
567
+ this.builtInOptions = listed;
568
+ while (select.firstChild) {
569
+ select.removeChild(select.firstChild);
528
570
  }
529
571
  const defaultOpt = document.createElement('option');
530
572
  defaultOpt.value = '';
531
573
  defaultOpt.disabled = true;
532
574
  defaultOpt.textContent = '-- Select --';
533
575
  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);
576
+ for (const option of options) {
539
577
  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
- }
578
+ optionEl.value = option.value;
579
+ optionEl.textContent = option.label;
545
580
  select.appendChild(optionEl);
546
- });
547
- select.addEventListener('change', (e) => {
548
- this.emitValue(e.target.value);
549
- });
550
- nativeEl.appendChild(select);
551
- return true;
581
+ }
582
+ }
583
+ const wanted = String(this.value);
584
+ const all = Array.from(select.options);
585
+ // Without a match the first option that can be chosen is shown, which is
586
+ // what a select built from scratch shows.
587
+ const chosen = all.find((option) => !option.disabled && option.value === wanted) ??
588
+ all.find((option) => !option.disabled);
589
+ if (chosen && !chosen.selected) {
590
+ chosen.selected = true;
552
591
  }
553
- return false;
554
592
  }
555
593
  renderError(message) {
556
594
  const el = document.createElement('div');
@@ -650,6 +688,33 @@ class FieldInput {
650
688
  onOptionsQuery = (query) => {
651
689
  this.loader?.setQuery(query);
652
690
  };
691
+ /**
692
+ * A masked field shows formatted text; `parseInput` turns what its renderer
693
+ * reports back into the value to store.
694
+ */
695
+ handleValueChange(input) {
696
+ const props = this.rendererProps;
697
+ const parseInput = props?.parseInput;
698
+ if (props &&
699
+ parseInput &&
700
+ typeof input === 'string' &&
701
+ input !== props.value) {
702
+ // The text on screen is no longer the text this field last handed down,
703
+ // and the text to show next may well be that same one: the mask takes
704
+ // nothing from a letter where a digit belongs, or from a character past
705
+ // its last place. A binding that does not change writes nothing, so the
706
+ // refused character would stay on screen. Tell the renderer what it
707
+ // holds now; whatever is shown next is then a change.
708
+ this.rendererProps = { ...props, value: input };
709
+ this.cdr.detectChanges();
710
+ this.rendererProps = props;
711
+ this.cdr.markForCheck();
712
+ }
713
+ this.onValueChangeField.emit({
714
+ value: parseInput ? parseInput(input) : input,
715
+ key: this.fieldDescription.name,
716
+ });
717
+ }
653
718
  ngOnChanges(_changes) {
654
719
  this.syncOptionsLoader();
655
720
  this.rendererProps = this.buildProps();
@@ -696,6 +761,7 @@ class FieldInput {
696
761
  touched: this.touched,
697
762
  dirty: this.dirty,
698
763
  optionsState: this.optionsState,
764
+ applyMask: true,
699
765
  });
700
766
  // Explicitly bound inputs override what the field description resolves to,
701
767
  // so a host can still drive options/disabled/error itself.
@@ -739,9 +805,7 @@ class FieldInput {
739
805
  [step]="p.step"
740
806
  [accept]="p.accept"
741
807
  [multiple]="p.multiple"
742
- (valueChange)="
743
- onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
744
- "
808
+ (valueChange)="handleValueChange($event)"
745
809
  (focusout)="onBlurField.emit(fieldDescription!.name)"
746
810
  [extraProps]="p.extraProps"
747
811
  [onOptionsQuery]="onOptionsQuery"
@@ -782,9 +846,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
782
846
  [step]="p.step"
783
847
  [accept]="p.accept"
784
848
  [multiple]="p.multiple"
785
- (valueChange)="
786
- onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
787
- "
849
+ (valueChange)="handleValueChange($event)"
788
850
  (focusout)="onBlurField.emit(fieldDescription!.name)"
789
851
  [extraProps]="p.extraProps"
790
852
  [onOptionsQuery]="onOptionsQuery"
@@ -821,6 +883,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
821
883
  type: Output
822
884
  }] } });
823
885
 
886
+ const NO_VALUES = Object.freeze({});
887
+ /**
888
+ * The fields of a titled section, or undefined for any other field. A field
889
+ * that also has `fields` is a repeatable group.
890
+ */
891
+ function sectionFieldsOf(field) {
892
+ return Array.isArray(field.section) && !Array.isArray(field.fields)
893
+ ? field.section
894
+ : undefined;
895
+ }
896
+ /** The object a section stores, or an empty one that keeps its identity. */
897
+ function sectionValuesOf(data, name) {
898
+ const value = data[name];
899
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
900
+ ? value
901
+ : NO_VALUES;
902
+ }
903
+ /**
904
+ * The entries of one section, keyed from inside it: `address.street` becomes
905
+ * `street`. An absent map stays absent, which a nested form reads as
906
+ * "uncontrolled".
907
+ */
908
+ function sectionPathMap(source, sectionName) {
909
+ if (source === undefined) {
910
+ return undefined;
911
+ }
912
+ const scoped = {};
913
+ const prefix = `${sectionName}.`;
914
+ for (const [path, value] of Object.entries(source)) {
915
+ if (path.startsWith(prefix) && path.length > prefix.length) {
916
+ scoped[path.slice(prefix.length)] = value;
917
+ }
918
+ }
919
+ return scoped;
920
+ }
921
+ /**
922
+ * Whether a section is disabled, by its flag or by its condition: the test
923
+ * validation applies before it skips the section.
924
+ */
925
+ function isSectionDisabled(field, data, rootData) {
926
+ return (field.disabled === true ||
927
+ field.disabledCondition?.(data, rootData) === true);
928
+ }
929
+
824
930
  const DEFAULT_BREAKPOINT = 768;
825
931
  // Per-instance id counter, so two forms rendering the same field name emit
826
932
  // different DOM ids.
@@ -886,6 +992,10 @@ class MultiFieldInput {
886
992
  indexedErrors = new Map();
887
993
  indexedTouchedSource;
888
994
  indexedTouched = new Map();
995
+ sectionErrorsSource;
996
+ sectionErrors = new Map();
997
+ sectionTouchedSource;
998
+ sectionTouched = new Map();
889
999
  get effectiveIdPrefix() {
890
1000
  return this.idPrefix ?? `dfk-${this.instanceId}`;
891
1001
  }
@@ -944,6 +1054,41 @@ class MultiFieldInput {
944
1054
  handleGroupBlur(fieldName, index, key) {
945
1055
  this.handleBlurField(`${fieldName}[${index}].${key}`);
946
1056
  }
1057
+ /** The fields of a titled section, or undefined for any other field. */
1058
+ sectionOf(field) {
1059
+ return sectionFieldsOf(field);
1060
+ }
1061
+ sectionValues(fieldName) {
1062
+ return sectionValuesOf(this.data, fieldName);
1063
+ }
1064
+ isSectionDisabled(field) {
1065
+ return isSectionDisabled(field, this.data, this.rootData ?? this.data);
1066
+ }
1067
+ // Cached per source map for the reason given at `touchedForItem`: a fresh
1068
+ // object in a template binding is a new identity on every pass.
1069
+ errorsForSection(fieldName) {
1070
+ if (this.sectionErrorsSource !== this.errors) {
1071
+ this.sectionErrorsSource = this.errors;
1072
+ this.sectionErrors.clear();
1073
+ }
1074
+ if (!this.sectionErrors.has(fieldName)) {
1075
+ this.sectionErrors.set(fieldName, sectionPathMap(this.errors, fieldName));
1076
+ }
1077
+ return this.sectionErrors.get(fieldName);
1078
+ }
1079
+ touchedForSection(fieldName) {
1080
+ const current = this.effectiveTouched;
1081
+ if (this.sectionTouchedSource !== current) {
1082
+ this.sectionTouchedSource = current;
1083
+ this.sectionTouched.clear();
1084
+ }
1085
+ let scoped = this.sectionTouched.get(fieldName);
1086
+ if (!scoped) {
1087
+ scoped = sectionPathMap(current, fieldName) ?? EMPTY_TOUCHED;
1088
+ this.sectionTouched.set(fieldName, scoped);
1089
+ }
1090
+ return scoped;
1091
+ }
947
1092
  /**
948
1093
  * Clears the internally tracked touched state. Only meaningful in
949
1094
  * uncontrolled mode - when `touched` is bound, resetting the form store
@@ -1125,7 +1270,7 @@ class MultiFieldInput {
1125
1270
  >
1126
1271
  <ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
1127
1272
  <dfk-field-input
1128
- *ngIf="!field.fields"
1273
+ *ngIf="!field.fields && !sectionOf(field)"
1129
1274
  [fieldDescription]="field"
1130
1275
  [data]="data"
1131
1276
  [rootData]="rootData"
@@ -1138,6 +1283,29 @@ class MultiFieldInput {
1138
1283
  (onBlurField)="handleBlurField($event)"
1139
1284
  ></dfk-field-input>
1140
1285
 
1286
+ <!--
1287
+ A titled section: one nested form over the object data[name], with
1288
+ the errors, touched flags and blur of its fields keyed
1289
+ name.childName. A fieldset names the group through its legend and
1290
+ disables every control inside it natively.
1291
+ -->
1292
+ <fieldset
1293
+ *ngIf="!field.fields && sectionOf(field) as section"
1294
+ [class]="field.className"
1295
+ [disabled]="isSectionDisabled(field)"
1296
+ >
1297
+ <legend *ngIf="field.label">{{ field.label }}</legend>
1298
+ <dfk-multi-field-input
1299
+ [fieldDescriptions]="section"
1300
+ [properties]="sectionValues(field.name)"
1301
+ [rootData]="rootData ?? data"
1302
+ [errors]="errorsForSection(field.name)"
1303
+ [touched]="touchedForSection(field.name)"
1304
+ (onBlurField)="handleBlurField(field.name + '.' + $event)"
1305
+ (onChange)="onFieldChange({ value: $event, key: field.name })"
1306
+ ></dfk-multi-field-input>
1307
+ </fieldset>
1308
+
1141
1309
  <div *ngIf="field.fields" [class]="field.className">
1142
1310
  <div *ngIf="field.label">{{ field.label }}</div>
1143
1311
  <div
@@ -1217,7 +1385,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1217
1385
  >
1218
1386
  <ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
1219
1387
  <dfk-field-input
1220
- *ngIf="!field.fields"
1388
+ *ngIf="!field.fields && !sectionOf(field)"
1221
1389
  [fieldDescription]="field"
1222
1390
  [data]="data"
1223
1391
  [rootData]="rootData"
@@ -1230,6 +1398,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1230
1398
  (onBlurField)="handleBlurField($event)"
1231
1399
  ></dfk-field-input>
1232
1400
 
1401
+ <!--
1402
+ A titled section: one nested form over the object data[name], with
1403
+ the errors, touched flags and blur of its fields keyed
1404
+ name.childName. A fieldset names the group through its legend and
1405
+ disables every control inside it natively.
1406
+ -->
1407
+ <fieldset
1408
+ *ngIf="!field.fields && sectionOf(field) as section"
1409
+ [class]="field.className"
1410
+ [disabled]="isSectionDisabled(field)"
1411
+ >
1412
+ <legend *ngIf="field.label">{{ field.label }}</legend>
1413
+ <dfk-multi-field-input
1414
+ [fieldDescriptions]="section"
1415
+ [properties]="sectionValues(field.name)"
1416
+ [rootData]="rootData ?? data"
1417
+ [errors]="errorsForSection(field.name)"
1418
+ [touched]="touchedForSection(field.name)"
1419
+ (onBlurField)="handleBlurField(field.name + '.' + $event)"
1420
+ (onChange)="onFieldChange({ value: $event, key: field.name })"
1421
+ ></dfk-multi-field-input>
1422
+ </fieldset>
1423
+
1233
1424
  <div *ngIf="field.fields" [class]="field.className">
1234
1425
  <div *ngIf="field.label">{{ field.label }}</div>
1235
1426
  <div
@@ -1616,6 +1807,58 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1616
1807
  type: Input
1617
1808
  }] } });
1618
1809
 
1810
+ function toFieldErrors(input) {
1811
+ const errors = {};
1812
+ for (const [path, value] of Object.entries(input)) {
1813
+ const messages = (typeof value === 'string' ? [value] : [...(value ?? [])])
1814
+ .filter((message) => typeof message === 'string')
1815
+ .map((message) => message.trim())
1816
+ .filter((message) => message !== '');
1817
+ if (messages.length > 0) {
1818
+ errors[path] = messages;
1819
+ }
1820
+ }
1821
+ return errors;
1822
+ }
1823
+ /** The form's own errors with the server's after them, field by field. */
1824
+ function mergeFieldErrors(own, server) {
1825
+ const paths = Object.keys(server);
1826
+ if (paths.length === 0) {
1827
+ return own;
1828
+ }
1829
+ const merged = { ...own };
1830
+ for (const path of paths) {
1831
+ merged[path] = [...(own[path] ?? []), ...server[path]];
1832
+ }
1833
+ return merged;
1834
+ }
1835
+ function valueAt(data, path) {
1836
+ let value = data;
1837
+ for (const segment of path.match(/[^.[\]]+/g) ?? []) {
1838
+ if (value === null || typeof value !== 'object') {
1839
+ return undefined;
1840
+ }
1841
+ value = value[segment];
1842
+ }
1843
+ return value;
1844
+ }
1845
+ /**
1846
+ * The server's errors without those of the fields whose value changed: the
1847
+ * server judged a value that is no longer the one in the field.
1848
+ */
1849
+ function withoutChangedFields(server, previous, next) {
1850
+ const paths = Object.keys(server);
1851
+ const kept = paths.filter((path) => Object.is(valueAt(previous, path), valueAt(next, path)));
1852
+ if (kept.length === paths.length) {
1853
+ return server;
1854
+ }
1855
+ return Object.fromEntries(kept.map((path) => [path, server[path]]));
1856
+ }
1857
+ /** The errors without those of one field. */
1858
+ function withoutField(errors, name) {
1859
+ return Object.fromEntries(Object.entries(errors).filter(([path]) => path !== name));
1860
+ }
1861
+
1619
1862
  function createDynamicFormStore(options) {
1620
1863
  const fields = options.fields;
1621
1864
  const initialValues = options.initialValues || {};
@@ -1630,6 +1873,22 @@ function createDynamicFormStore(options) {
1630
1873
  // changes. See the React adapter for the full rationale.
1631
1874
  const baselineValues = signal({ ...data() }, ...(ngDevMode ? [{ debugName: "baselineValues" }] : /* istanbul ignore next */ []));
1632
1875
  const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
1876
+ // `errors` stays the writable signal it was, holding what is shown: the
1877
+ // form's own errors with the server's after them. The two parts are kept
1878
+ // apart here so that either can change without losing the other.
1879
+ let ownErrors = {};
1880
+ const serverErrors = signal({}, ...(ngDevMode ? [{ debugName: "serverErrors" }] : /* istanbul ignore next */ []));
1881
+ function showErrors(own) {
1882
+ ownErrors = own;
1883
+ errors.set(mergeFieldErrors(own, serverErrors()));
1884
+ }
1885
+ function showServerErrors(server) {
1886
+ if (server === serverErrors()) {
1887
+ return;
1888
+ }
1889
+ serverErrors.set(server);
1890
+ errors.set(mergeFieldErrors(ownErrors, server));
1891
+ }
1633
1892
  const isDirty = signal(false, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
1634
1893
  const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
1635
1894
  const isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : /* istanbul ignore next */ []));
@@ -1651,7 +1910,7 @@ function createDynamicFormStore(options) {
1651
1910
  }
1652
1911
  function validate() {
1653
1912
  const res = validateFields(fields, data(), undefined, validationContext);
1654
- errors.set(res.errors);
1913
+ showErrors(res.errors);
1655
1914
  return commitSyncResult(res);
1656
1915
  }
1657
1916
  async function validateAsync() {
@@ -1669,7 +1928,7 @@ function createDynamicFormStore(options) {
1669
1928
  if (run !== validationRun || data() !== snapshot) {
1670
1929
  return res.valid;
1671
1930
  }
1672
- errors.set(res.errors);
1931
+ showErrors(res.errors);
1673
1932
  validationResult.set(res);
1674
1933
  return res.valid;
1675
1934
  }
@@ -1681,6 +1940,8 @@ function createDynamicFormStore(options) {
1681
1940
  }
1682
1941
  function handleChange(newData) {
1683
1942
  const next = applyComputedValues(fields, newData);
1943
+ // The server judged the old value: its verdict goes with it.
1944
+ showServerErrors(withoutChangedFields(serverErrors(), data(), next));
1684
1945
  data.set(next);
1685
1946
  isDirty.set(true);
1686
1947
  validationController?.abort();
@@ -1689,7 +1950,7 @@ function createDynamicFormStore(options) {
1689
1950
  const res = validateFields(fields, next, undefined, validationContext);
1690
1951
  commitSyncResult(res);
1691
1952
  if (validateOnChange) {
1692
- errors.set(res.errors);
1953
+ showErrors(res.errors);
1693
1954
  }
1694
1955
  }
1695
1956
  function setFieldValue(name, value) {
@@ -1721,11 +1982,36 @@ function createDynamicFormStore(options) {
1721
1982
  function resetTouched() {
1722
1983
  touched.set({});
1723
1984
  }
1985
+ /**
1986
+ * Shows the errors a backend answered with, and marks their fields touched
1987
+ * so they are displayed. An error stays until the value it was about
1988
+ * changes, until `clearServerErrors` or `reset`, or until the next submit.
1989
+ * `isValid` is not affected: it speaks for the form's own rules. Core's
1990
+ * `serverErrorsFrom(body)` turns the usual response shapes into this map.
1991
+ */
1992
+ function setServerErrors(input) {
1993
+ const next = toFieldErrors(input);
1994
+ showServerErrors(next);
1995
+ touched.set({
1996
+ ...touched(),
1997
+ ...Object.fromEntries(Object.keys(next).map((path) => [path, true])),
1998
+ });
1999
+ }
2000
+ /** Removes the server errors of one field, or all of them. */
2001
+ function clearServerErrors(name) {
2002
+ if (name === undefined) {
2003
+ showServerErrors({});
2004
+ return;
2005
+ }
2006
+ if (name in serverErrors()) {
2007
+ showServerErrors(withoutField(serverErrors(), name));
2008
+ }
2009
+ }
1724
2010
  function handleBlur(fieldName) {
1725
2011
  setFieldTouched(fieldName, true);
1726
2012
  if (validateOnBlur) {
1727
2013
  const res = validateFields(fields, data(), undefined, validationContext);
1728
- errors.set(res.errors);
2014
+ showErrors(res.errors);
1729
2015
  commitSyncResult(res);
1730
2016
  }
1731
2017
  }
@@ -1734,7 +2020,8 @@ function createDynamicFormStore(options) {
1734
2020
  const next = applyComputedValues(fields, seed);
1735
2021
  data.set(next);
1736
2022
  baselineValues.set({ ...next });
1737
- errors.set({});
2023
+ serverErrors.set({});
2024
+ showErrors({});
1738
2025
  isDirty.set(false);
1739
2026
  touched.set({});
1740
2027
  isSubmitting.set(false);
@@ -1755,6 +2042,8 @@ function createDynamicFormStore(options) {
1755
2042
  e.preventDefault();
1756
2043
  }
1757
2044
  isSubmitting.set(true);
2045
+ // A new attempt: what the server said about the last one is history.
2046
+ showServerErrors({});
1758
2047
  const thisSubmit = ++submitRun;
1759
2048
  try {
1760
2049
  // Touch everything before validating: a submit is the user asserting
@@ -1783,12 +2072,12 @@ function createDynamicFormStore(options) {
1783
2072
  // still has to describe the data on screen, so when it moved on, the
1784
2073
  // displayed state is re-derived instead of showing the old pass.
1785
2074
  if (data() === snapshot && run === validationRun) {
1786
- errors.set(res.errors);
2075
+ showErrors(res.errors);
1787
2076
  validationResult.set(res);
1788
2077
  }
1789
2078
  else {
1790
2079
  const live = validateFields(fields, data(), undefined, validationContext);
1791
- errors.set(live.errors);
2080
+ showErrors(live.errors);
1792
2081
  validationResult.set(live);
1793
2082
  }
1794
2083
  isSubmitted.set(true);
@@ -1829,6 +2118,10 @@ function createDynamicFormStore(options) {
1829
2118
  reset,
1830
2119
  validate,
1831
2120
  validateAsync,
2121
+ /** What the server refused, by field path; already part of `errors`. */
2122
+ serverErrors: serverErrors.asReadonly(),
2123
+ setServerErrors,
2124
+ clearServerErrors,
1832
2125
  handleSubmit,
1833
2126
  };
1834
2127
  }
@@ -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.0",
4
4
  "description": "Angular renderer for dynamic-field-kit",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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",