ngx-dynamic-entity 1.9.1 → 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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, computed, HostListener, ViewChildren, Output, ElementRef, afterNextRender } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, ElementRef, computed, afterNextRender, HostListener, ViewChildren, Output } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
4
  import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup } from '@angular/forms';
5
5
  import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, ambiguousFieldIds, parseFieldRef, toRefToken, collectFieldScopes, fieldRefFor, refOf, getTabPath, applyAutoPatch, applyPatchOnTrue } from '@dynamic-entity/core';
@@ -268,6 +268,30 @@ const DEFAULT_VALIDATION_MESSAGES = {
268
268
  invalidNumber: 'Invalid number.',
269
269
  invalidSelection: 'Invalid selection.',
270
270
  };
271
+ /**
272
+ * The keys a plain input can raise, most specific first.
273
+ *
274
+ * A type that cannot raise one of these is unaffected by its presence — `resolve` returns the
275
+ * first error actually on the control — so the shared list costs nothing and spares every
276
+ * field component a near-identical copy of it.
277
+ */
278
+ const COMMON_ORDER = ['required', 'email', 'min', 'max', 'minlength', 'maxlength', 'pattern'];
279
+ /**
280
+ * Where a field type disagrees with the common list.
281
+ *
282
+ * Only three do, and each for a reason about how the control reads rather than what it can
283
+ * raise: a select says "Please select an option" where an input says "This field is
284
+ * required"; a number that is neither required nor out of range is not "invalid", it is not a
285
+ * number; and a control with no text in it cannot fail a length or format rule.
286
+ */
287
+ const ORDER_BY_FIELD_TYPE = {
288
+ dropdown: { order: [['required', 'requiredSelection']], fallback: 'invalidSelection' },
289
+ number: { order: ['required', 'min', 'max'], fallback: 'invalidNumber' },
290
+ boolean: { order: ['required', 'pattern'], fallback: 'invalid' },
291
+ checkbox: { order: ['required', 'pattern'], fallback: 'invalid' },
292
+ file: { order: ['required', 'pattern'], fallback: 'invalid' },
293
+ image: { order: ['required', 'pattern'], fallback: 'invalid' },
294
+ };
271
295
  class ValidationMessagesService {
272
296
  overrides = inject(VALIDATION_MESSAGES, { optional: true }) ?? {};
273
297
  /**
@@ -292,6 +316,27 @@ class ValidationMessagesService {
292
316
  }
293
317
  return this.messageFor(fallbackKey, language, null);
294
318
  }
319
+ /**
320
+ * The message for a field, chosen by its **type** rather than by a list the caller supplies.
321
+ *
322
+ * Every field component used to pass its own copy of the key order, and the copies were
323
+ * near-identical — which was tolerable while the field itself was the only thing that
324
+ * rendered a message. It stopped being tolerable when the error summary above the form
325
+ * started naming what is wrong with each field: the summary has no field component to ask
326
+ * (the offending field is usually on a tab that is not rendered), so it would have needed a
327
+ * second copy of the same table, and a dropdown would have said "This field is required" in
328
+ * the summary and "Please select an option" under the control.
329
+ *
330
+ * One table, read from both places, is what keeps those two sentences the same sentence.
331
+ */
332
+ resolveForField(errors, language, fieldType) {
333
+ const { order, fallback } = this.orderFor(fieldType);
334
+ return this.resolve(errors, language, order, fallback);
335
+ }
336
+ /** A field type's key order. An unrecognised type — a consumer's own — gets the common list. */
337
+ orderFor(fieldType) {
338
+ return ORDER_BY_FIELD_TYPE[fieldType] ?? { order: COMMON_ORDER, fallback: 'invalid' };
339
+ }
295
340
  /** One message by key, honouring an override before the default. */
296
341
  messageFor(key, language, error) {
297
342
  const message = this.overrides[key] ?? DEFAULT_VALIDATION_MESSAGES[key];
@@ -342,6 +387,29 @@ function nextFieldInstanceId() {
342
387
  function fieldDomId(field, instance, suffix = '') {
343
388
  return `${field?.id ?? 'field'}-${instance}${suffix}`;
344
389
  }
390
+ /**
391
+ * The `aria-describedby` a control needs, given what is rendered beside it right now.
392
+ *
393
+ * A field can show two things under its control — the author's help text and, once touched, a
394
+ * validation message — and a screen reader announces only what this attribute names. Three
395
+ * field components named the error; the other sixteen named nothing at all, so their error
396
+ * messages were on screen and silent. Help text would have inherited the same gap.
397
+ *
398
+ * Order matters: the hint describes the field and the error describes the attempt, so the hint
399
+ * is read first. `null` rather than an empty string when there is nothing to describe — an
400
+ * empty `aria-describedby` is a dangling reference, not an absent one.
401
+ *
402
+ * Shared rather than written into each of the nineteen field components, because the rule is
403
+ * the same everywhere and getting it wrong is invisible until somebody uses a screen reader.
404
+ */
405
+ function fieldDescribedBy(domId, parts) {
406
+ const ids = [];
407
+ if (parts.hint)
408
+ ids.push(domId('-hint'));
409
+ if (parts.error)
410
+ ids.push(domId('-error'));
411
+ return ids.length ? ids.join(' ') : null;
412
+ }
345
413
 
346
414
  class TextFieldComponent {
347
415
  /**
@@ -381,16 +449,18 @@ class TextFieldComponent {
381
449
  get placeholder() {
382
450
  return resolveLabel(this.field?.placeholder, this.language);
383
451
  }
452
+ /** Author help text, shown under the control and named by `aria-describedby`. */
453
+ get hint() {
454
+ return resolveLabel(this.field?.hint, this.language);
455
+ }
456
+ /** The ids of whatever is describing this control right now. */
457
+ describedBy() {
458
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
459
+ }
384
460
  get errorMessage() {
385
461
  if (!this.control || !this.control.errors || !this.control.touched)
386
462
  return '';
387
- return this.messages.resolve(this.control.errors, this.language, [
388
- 'required',
389
- 'email',
390
- 'minlength',
391
- 'maxlength',
392
- 'pattern',
393
- ]);
463
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
394
464
  }
395
465
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
396
466
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: TextFieldComponent, isStandalone: true, selector: "ngx-text-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -402,12 +472,26 @@ class TextFieldComponent {
402
472
  [class.ngx-field--masked]="masked"
403
473
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
404
474
  >
405
- <label class="ngx-field__label" [attr.for]="domId()"
406
- >{{ label }}
407
- @if (field.validators?.required) {
408
- <span class="ngx-field__req">*</span>
475
+ <div class="ngx-field__label-row">
476
+ <label class="ngx-field__label" [attr.for]="domId()"
477
+ >{{ label }}
478
+ @if (field.validators?.required) {
479
+ <span class="ngx-field__req">*</span>
480
+ }
481
+ </label>
482
+ @if (hint) {
483
+ <span class="ngx-field__hint-wrap">
484
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
485
+ <span
486
+ class="ngx-field__hint"
487
+ role="tooltip"
488
+ [attr.data-testid]="'field-' + field.id + '-hint'"
489
+ [id]="domId('-hint')"
490
+ >{{ hint }}</span
491
+ >
492
+ </span>
409
493
  }
410
- </label>
494
+ </div>
411
495
  @if (masked) {
412
496
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
413
497
  maskedText
@@ -424,7 +508,7 @@ class TextFieldComponent {
424
508
  [placeholder]="placeholder"
425
509
  [attr.disabled]="field.disabled ? true : null"
426
510
  [attr.aria-invalid]="control.invalid && control.touched"
427
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
511
+ [attr.aria-describedby]="describedBy()"
428
512
  />
429
513
  @if (errorMessage) {
430
514
  <span
@@ -455,12 +539,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
455
539
  [class.ngx-field--masked]="masked"
456
540
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
457
541
  >
458
- <label class="ngx-field__label" [attr.for]="domId()"
459
- >{{ label }}
460
- @if (field.validators?.required) {
461
- <span class="ngx-field__req">*</span>
542
+ <div class="ngx-field__label-row">
543
+ <label class="ngx-field__label" [attr.for]="domId()"
544
+ >{{ label }}
545
+ @if (field.validators?.required) {
546
+ <span class="ngx-field__req">*</span>
547
+ }
548
+ </label>
549
+ @if (hint) {
550
+ <span class="ngx-field__hint-wrap">
551
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
552
+ <span
553
+ class="ngx-field__hint"
554
+ role="tooltip"
555
+ [attr.data-testid]="'field-' + field.id + '-hint'"
556
+ [id]="domId('-hint')"
557
+ >{{ hint }}</span
558
+ >
559
+ </span>
462
560
  }
463
- </label>
561
+ </div>
464
562
  @if (masked) {
465
563
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
466
564
  maskedText
@@ -477,7 +575,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
477
575
  [placeholder]="placeholder"
478
576
  [attr.disabled]="field.disabled ? true : null"
479
577
  [attr.aria-invalid]="control.invalid && control.touched"
480
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
578
+ [attr.aria-describedby]="describedBy()"
481
579
  />
482
580
  @if (errorMessage) {
483
581
  <span
@@ -533,18 +631,18 @@ class TextareaFieldComponent {
533
631
  * "This field has an error", which made a documented, configurable feature work on three
534
632
  * of fifteen field types.
535
633
  */
634
+ /** Author help text, shown under the control and named by `aria-describedby`. */
635
+ get hint() {
636
+ return resolveLabel(this.field?.hint, this.language);
637
+ }
638
+ /** The ids of whatever is describing this control right now. */
639
+ describedBy() {
640
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
641
+ }
536
642
  get errorMessage() {
537
643
  if (!this.control?.errors || !this.control.touched)
538
644
  return '';
539
- return this.messages.resolve(this.control.errors, this.language, [
540
- 'required',
541
- 'email',
542
- 'min',
543
- 'max',
544
- 'minlength',
545
- 'maxlength',
546
- 'pattern',
547
- ]);
645
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
548
646
  }
549
647
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TextareaFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
550
648
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: TextareaFieldComponent, isStandalone: true, selector: "ngx-textarea-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -555,7 +653,21 @@ class TextareaFieldComponent {
555
653
  [class.ngx-field--readonly]="readonly"
556
654
  [class.ngx-field--masked]="masked"
557
655
  >
558
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
656
+ <div class="ngx-field__label-row">
657
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
658
+ @if (hint) {
659
+ <span class="ngx-field__hint-wrap">
660
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
661
+ <span
662
+ class="ngx-field__hint"
663
+ role="tooltip"
664
+ [attr.data-testid]="'field-' + field.id + '-hint'"
665
+ [id]="domId('-hint')"
666
+ >{{ hint }}</span
667
+ >
668
+ </span>
669
+ }
670
+ </div>
559
671
  @if (masked) {
560
672
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
561
673
  maskedText
@@ -568,12 +680,15 @@ class TextareaFieldComponent {
568
680
  class="ngx-field__input"
569
681
  [attr.data-testid]="'field-' + field.id + '-input'"
570
682
  [formControl]="$any(control)"
683
+ [attr.aria-describedby]="describedBy()"
571
684
  [placeholder]="placeholder"
572
685
  [attr.disabled]="field.disabled ? true : null"
573
686
  rows="3"
574
687
  ></textarea>
575
688
  @if (errorMessage) {
576
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
689
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
690
+ errorMessage
691
+ }}</span>
577
692
  }
578
693
  }
579
694
  </div>
@@ -594,7 +709,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
594
709
  [class.ngx-field--readonly]="readonly"
595
710
  [class.ngx-field--masked]="masked"
596
711
  >
597
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
712
+ <div class="ngx-field__label-row">
713
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
714
+ @if (hint) {
715
+ <span class="ngx-field__hint-wrap">
716
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
717
+ <span
718
+ class="ngx-field__hint"
719
+ role="tooltip"
720
+ [attr.data-testid]="'field-' + field.id + '-hint'"
721
+ [id]="domId('-hint')"
722
+ >{{ hint }}</span
723
+ >
724
+ </span>
725
+ }
726
+ </div>
598
727
  @if (masked) {
599
728
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
600
729
  maskedText
@@ -607,12 +736,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
607
736
  class="ngx-field__input"
608
737
  [attr.data-testid]="'field-' + field.id + '-input'"
609
738
  [formControl]="$any(control)"
739
+ [attr.aria-describedby]="describedBy()"
610
740
  [placeholder]="placeholder"
611
741
  [attr.disabled]="field.disabled ? true : null"
612
742
  rows="3"
613
743
  ></textarea>
614
744
  @if (errorMessage) {
615
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
745
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
746
+ errorMessage
747
+ }}</span>
616
748
  }
617
749
  }
618
750
  </div>
@@ -654,6 +786,9 @@ const DEFAULT_UI_TEXT = {
654
786
  reset: 'Reset',
655
787
  saveShortcutHint: 'Shortcut: Ctrl+S',
656
788
  loading: 'Loading…',
789
+ // Why a save was refused — the summary above the form, and the count on each tab
790
+ errorSummaryTitle: '{count} field(s) need attention before this can be saved.',
791
+ tabErrorCount: '{count} field(s) need attention on this tab',
657
792
  // Record view — the per-tab edit flow
658
793
  editSection: 'Edit section',
659
794
  saveSection: 'Save section',
@@ -896,18 +1031,18 @@ class MarkdownFieldComponent {
896
1031
  * "This field has an error", which made a documented, configurable feature work on three
897
1032
  * of fifteen field types.
898
1033
  */
1034
+ /** Author help text, shown under the control and named by `aria-describedby`. */
1035
+ get hint() {
1036
+ return resolveLabel(this.field?.hint, this.language);
1037
+ }
1038
+ /** The ids of whatever is describing this control right now. */
1039
+ describedBy() {
1040
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
1041
+ }
899
1042
  get errorMessage() {
900
1043
  if (!this.control?.errors || !this.control.touched)
901
1044
  return '';
902
- return this.messages.resolve(this.control.errors, this.language, [
903
- 'required',
904
- 'email',
905
- 'min',
906
- 'max',
907
- 'minlength',
908
- 'maxlength',
909
- 'pattern',
910
- ]);
1045
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
911
1046
  }
912
1047
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MarkdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
913
1048
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: MarkdownFieldComponent, isStandalone: true, selector: "ngx-markdown-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, usesOnChanges: true, ngImport: i0, template: `
@@ -918,7 +1053,21 @@ class MarkdownFieldComponent {
918
1053
  [class.ngx-field--readonly]="readonly"
919
1054
  [class.ngx-field--masked]="masked"
920
1055
  >
921
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1056
+ <div class="ngx-field__label-row">
1057
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1058
+ @if (hint) {
1059
+ <span class="ngx-field__hint-wrap">
1060
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1061
+ <span
1062
+ class="ngx-field__hint"
1063
+ role="tooltip"
1064
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1065
+ [id]="domId('-hint')"
1066
+ >{{ hint }}</span
1067
+ >
1068
+ </span>
1069
+ }
1070
+ </div>
922
1071
 
923
1072
  @if (masked) {
924
1073
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
@@ -979,6 +1128,7 @@ class MarkdownFieldComponent {
979
1128
  class="ngx-field__input ngx-field__input--markdown"
980
1129
  [attr.data-testid]="'field-' + field.id + '-input'"
981
1130
  [formControl]="$any(control)"
1131
+ [attr.aria-describedby]="describedBy()"
982
1132
  [placeholder]="placeholder"
983
1133
  [attr.disabled]="field.disabled ? true : null"
984
1134
  rows="6"
@@ -987,7 +1137,9 @@ class MarkdownFieldComponent {
987
1137
  </div>
988
1138
 
989
1139
  @if (errorMessage) {
990
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1140
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1141
+ errorMessage
1142
+ }}</span>
991
1143
  }
992
1144
  }
993
1145
  </div>
@@ -1008,7 +1160,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1008
1160
  [class.ngx-field--readonly]="readonly"
1009
1161
  [class.ngx-field--masked]="masked"
1010
1162
  >
1011
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1163
+ <div class="ngx-field__label-row">
1164
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1165
+ @if (hint) {
1166
+ <span class="ngx-field__hint-wrap">
1167
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1168
+ <span
1169
+ class="ngx-field__hint"
1170
+ role="tooltip"
1171
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1172
+ [id]="domId('-hint')"
1173
+ >{{ hint }}</span
1174
+ >
1175
+ </span>
1176
+ }
1177
+ </div>
1012
1178
 
1013
1179
  @if (masked) {
1014
1180
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
@@ -1069,6 +1235,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1069
1235
  class="ngx-field__input ngx-field__input--markdown"
1070
1236
  [attr.data-testid]="'field-' + field.id + '-input'"
1071
1237
  [formControl]="$any(control)"
1238
+ [attr.aria-describedby]="describedBy()"
1072
1239
  [placeholder]="placeholder"
1073
1240
  [attr.disabled]="field.disabled ? true : null"
1074
1241
  rows="6"
@@ -1077,7 +1244,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1077
1244
  </div>
1078
1245
 
1079
1246
  @if (errorMessage) {
1080
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1247
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1248
+ errorMessage
1249
+ }}</span>
1081
1250
  }
1082
1251
  }
1083
1252
  </div>
@@ -1118,10 +1287,18 @@ class NumberFieldComponent {
1118
1287
  get placeholder() {
1119
1288
  return resolveLabel(this.field?.placeholder, this.language);
1120
1289
  }
1290
+ /** Author help text, shown under the control and named by `aria-describedby`. */
1291
+ get hint() {
1292
+ return resolveLabel(this.field?.hint, this.language);
1293
+ }
1294
+ /** The ids of whatever is describing this control right now. */
1295
+ describedBy() {
1296
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
1297
+ }
1121
1298
  get errorMessage() {
1122
1299
  if (!this.control || !this.control.errors || !this.control.touched)
1123
1300
  return '';
1124
- return this.messages.resolve(this.control.errors, this.language, ['required', 'min', 'max'], 'invalidNumber');
1301
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
1125
1302
  }
1126
1303
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: NumberFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1127
1304
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: NumberFieldComponent, isStandalone: true, selector: "ngx-number-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1133,12 +1310,26 @@ class NumberFieldComponent {
1133
1310
  [class.ngx-field--masked]="masked"
1134
1311
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
1135
1312
  >
1136
- <label class="ngx-field__label" [attr.for]="domId()">
1137
- {{ label }}
1138
- @if (field.validators?.required) {
1139
- <span class="ngx-field__req">*</span>
1313
+ <div class="ngx-field__label-row">
1314
+ <label class="ngx-field__label" [attr.for]="domId()">
1315
+ {{ label }}
1316
+ @if (field.validators?.required) {
1317
+ <span class="ngx-field__req">*</span>
1318
+ }
1319
+ </label>
1320
+ @if (hint) {
1321
+ <span class="ngx-field__hint-wrap">
1322
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1323
+ <span
1324
+ class="ngx-field__hint"
1325
+ role="tooltip"
1326
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1327
+ [id]="domId('-hint')"
1328
+ >{{ hint }}</span
1329
+ >
1330
+ </span>
1140
1331
  }
1141
- </label>
1332
+ </div>
1142
1333
  @if (masked) {
1143
1334
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1144
1335
  maskedText
@@ -1155,7 +1346,7 @@ class NumberFieldComponent {
1155
1346
  [placeholder]="placeholder"
1156
1347
  [attr.disabled]="field.disabled ? true : null"
1157
1348
  [attr.aria-invalid]="control.invalid && control.touched"
1158
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
1349
+ [attr.aria-describedby]="describedBy()"
1159
1350
  />
1160
1351
  @if (errorMessage) {
1161
1352
  <span
@@ -1186,12 +1377,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1186
1377
  [class.ngx-field--masked]="masked"
1187
1378
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
1188
1379
  >
1189
- <label class="ngx-field__label" [attr.for]="domId()">
1190
- {{ label }}
1191
- @if (field.validators?.required) {
1192
- <span class="ngx-field__req">*</span>
1380
+ <div class="ngx-field__label-row">
1381
+ <label class="ngx-field__label" [attr.for]="domId()">
1382
+ {{ label }}
1383
+ @if (field.validators?.required) {
1384
+ <span class="ngx-field__req">*</span>
1385
+ }
1386
+ </label>
1387
+ @if (hint) {
1388
+ <span class="ngx-field__hint-wrap">
1389
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1390
+ <span
1391
+ class="ngx-field__hint"
1392
+ role="tooltip"
1393
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1394
+ [id]="domId('-hint')"
1395
+ >{{ hint }}</span
1396
+ >
1397
+ </span>
1193
1398
  }
1194
- </label>
1399
+ </div>
1195
1400
  @if (masked) {
1196
1401
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1197
1402
  maskedText
@@ -1208,7 +1413,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1208
1413
  [placeholder]="placeholder"
1209
1414
  [attr.disabled]="field.disabled ? true : null"
1210
1415
  [attr.aria-invalid]="control.invalid && control.touched"
1211
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
1416
+ [attr.aria-describedby]="describedBy()"
1212
1417
  />
1213
1418
  @if (errorMessage) {
1214
1419
  <span
@@ -1277,18 +1482,18 @@ class CurrencyFieldComponent {
1277
1482
  * "This field has an error", which made a documented, configurable feature work on three
1278
1483
  * of fifteen field types.
1279
1484
  */
1485
+ /** Author help text, shown under the control and named by `aria-describedby`. */
1486
+ get hint() {
1487
+ return resolveLabel(this.field?.hint, this.language);
1488
+ }
1489
+ /** The ids of whatever is describing this control right now. */
1490
+ describedBy() {
1491
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
1492
+ }
1280
1493
  get errorMessage() {
1281
1494
  if (!this.control?.errors || !this.control.touched)
1282
1495
  return '';
1283
- return this.messages.resolve(this.control.errors, this.language, [
1284
- 'required',
1285
- 'email',
1286
- 'min',
1287
- 'max',
1288
- 'minlength',
1289
- 'maxlength',
1290
- 'pattern',
1291
- ]);
1496
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
1292
1497
  }
1293
1498
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: CurrencyFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1294
1499
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: CurrencyFieldComponent, isStandalone: true, selector: "ngx-currency-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1299,7 +1504,21 @@ class CurrencyFieldComponent {
1299
1504
  [class.ngx-field--readonly]="readonly"
1300
1505
  [class.ngx-field--masked]="masked"
1301
1506
  >
1302
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1507
+ <div class="ngx-field__label-row">
1508
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1509
+ @if (hint) {
1510
+ <span class="ngx-field__hint-wrap">
1511
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1512
+ <span
1513
+ class="ngx-field__hint"
1514
+ role="tooltip"
1515
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1516
+ [id]="domId('-hint')"
1517
+ >{{ hint }}</span
1518
+ >
1519
+ </span>
1520
+ }
1521
+ </div>
1303
1522
  @if (masked) {
1304
1523
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1305
1524
  maskedText
@@ -1318,12 +1537,15 @@ class CurrencyFieldComponent {
1318
1537
  type="number"
1319
1538
  step="0.01"
1320
1539
  [formControl]="$any(control)"
1540
+ [attr.aria-describedby]="describedBy()"
1321
1541
  [placeholder]="placeholder || '0.00'"
1322
1542
  [attr.disabled]="field.disabled ? true : null"
1323
1543
  />
1324
1544
  </div>
1325
1545
  @if (errorMessage) {
1326
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1546
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1547
+ errorMessage
1548
+ }}</span>
1327
1549
  }
1328
1550
  }
1329
1551
  </div>
@@ -1344,7 +1566,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1344
1566
  [class.ngx-field--readonly]="readonly"
1345
1567
  [class.ngx-field--masked]="masked"
1346
1568
  >
1347
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1569
+ <div class="ngx-field__label-row">
1570
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1571
+ @if (hint) {
1572
+ <span class="ngx-field__hint-wrap">
1573
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1574
+ <span
1575
+ class="ngx-field__hint"
1576
+ role="tooltip"
1577
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1578
+ [id]="domId('-hint')"
1579
+ >{{ hint }}</span
1580
+ >
1581
+ </span>
1582
+ }
1583
+ </div>
1348
1584
  @if (masked) {
1349
1585
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1350
1586
  maskedText
@@ -1363,12 +1599,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1363
1599
  type="number"
1364
1600
  step="0.01"
1365
1601
  [formControl]="$any(control)"
1602
+ [attr.aria-describedby]="describedBy()"
1366
1603
  [placeholder]="placeholder || '0.00'"
1367
1604
  [attr.disabled]="field.disabled ? true : null"
1368
1605
  />
1369
1606
  </div>
1370
1607
  @if (errorMessage) {
1371
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1608
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1609
+ errorMessage
1610
+ }}</span>
1372
1611
  }
1373
1612
  }
1374
1613
  </div>
@@ -1416,18 +1655,18 @@ class EmailFieldComponent {
1416
1655
  * "This field has an error", which made a documented, configurable feature work on three
1417
1656
  * of fifteen field types.
1418
1657
  */
1658
+ /** Author help text, shown under the control and named by `aria-describedby`. */
1659
+ get hint() {
1660
+ return resolveLabel(this.field?.hint, this.language);
1661
+ }
1662
+ /** The ids of whatever is describing this control right now. */
1663
+ describedBy() {
1664
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
1665
+ }
1419
1666
  get errorMessage() {
1420
1667
  if (!this.control?.errors || !this.control.touched)
1421
1668
  return '';
1422
- return this.messages.resolve(this.control.errors, this.language, [
1423
- 'required',
1424
- 'email',
1425
- 'min',
1426
- 'max',
1427
- 'minlength',
1428
- 'maxlength',
1429
- 'pattern',
1430
- ]);
1669
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
1431
1670
  }
1432
1671
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EmailFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1433
1672
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EmailFieldComponent, isStandalone: true, selector: "ngx-email-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1438,7 +1677,21 @@ class EmailFieldComponent {
1438
1677
  [class.ngx-field--readonly]="readonly"
1439
1678
  [class.ngx-field--masked]="masked"
1440
1679
  >
1441
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1680
+ <div class="ngx-field__label-row">
1681
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1682
+ @if (hint) {
1683
+ <span class="ngx-field__hint-wrap">
1684
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1685
+ <span
1686
+ class="ngx-field__hint"
1687
+ role="tooltip"
1688
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1689
+ [id]="domId('-hint')"
1690
+ >{{ hint }}</span
1691
+ >
1692
+ </span>
1693
+ }
1694
+ </div>
1442
1695
  @if (masked) {
1443
1696
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1444
1697
  maskedText
@@ -1455,6 +1708,7 @@ class EmailFieldComponent {
1455
1708
  type="email"
1456
1709
  autocomplete="email"
1457
1710
  [formControl]="$any(control)"
1711
+ [attr.aria-describedby]="describedBy()"
1458
1712
  [placeholder]="placeholder || 'you@example.com'"
1459
1713
  [attr.disabled]="field.disabled ? true : null"
1460
1714
  />
@@ -1463,7 +1717,9 @@ class EmailFieldComponent {
1463
1717
  required" and "Enter a valid email address" itself, so a host configuring
1464
1718
  validationMessages was overridden by markup it could not reach. The service
1465
1719
  defaults say the same thing. -->
1466
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1720
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1721
+ errorMessage
1722
+ }}</span>
1467
1723
  }
1468
1724
  }
1469
1725
  </div>
@@ -1484,7 +1740,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1484
1740
  [class.ngx-field--readonly]="readonly"
1485
1741
  [class.ngx-field--masked]="masked"
1486
1742
  >
1487
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1743
+ <div class="ngx-field__label-row">
1744
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1745
+ @if (hint) {
1746
+ <span class="ngx-field__hint-wrap">
1747
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1748
+ <span
1749
+ class="ngx-field__hint"
1750
+ role="tooltip"
1751
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1752
+ [id]="domId('-hint')"
1753
+ >{{ hint }}</span
1754
+ >
1755
+ </span>
1756
+ }
1757
+ </div>
1488
1758
  @if (masked) {
1489
1759
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1490
1760
  maskedText
@@ -1501,6 +1771,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1501
1771
  type="email"
1502
1772
  autocomplete="email"
1503
1773
  [formControl]="$any(control)"
1774
+ [attr.aria-describedby]="describedBy()"
1504
1775
  [placeholder]="placeholder || 'you@example.com'"
1505
1776
  [attr.disabled]="field.disabled ? true : null"
1506
1777
  />
@@ -1509,7 +1780,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1509
1780
  required" and "Enter a valid email address" itself, so a host configuring
1510
1781
  validationMessages was overridden by markup it could not reach. The service
1511
1782
  defaults say the same thing. -->
1512
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1783
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1784
+ errorMessage
1785
+ }}</span>
1513
1786
  }
1514
1787
  }
1515
1788
  </div>
@@ -1563,18 +1836,18 @@ class PasswordFieldComponent {
1563
1836
  * "This field has an error", which made a documented, configurable feature work on three
1564
1837
  * of fifteen field types.
1565
1838
  */
1839
+ /** Author help text, shown under the control and named by `aria-describedby`. */
1840
+ get hint() {
1841
+ return resolveLabel(this.field?.hint, this.language);
1842
+ }
1843
+ /** The ids of whatever is describing this control right now. */
1844
+ describedBy() {
1845
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
1846
+ }
1566
1847
  get errorMessage() {
1567
1848
  if (!this.control?.errors || !this.control.touched)
1568
1849
  return '';
1569
- return this.messages.resolve(this.control.errors, this.language, [
1570
- 'required',
1571
- 'email',
1572
- 'min',
1573
- 'max',
1574
- 'minlength',
1575
- 'maxlength',
1576
- 'pattern',
1577
- ]);
1850
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
1578
1851
  }
1579
1852
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: PasswordFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1580
1853
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: PasswordFieldComponent, isStandalone: true, selector: "ngx-password-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1585,7 +1858,21 @@ class PasswordFieldComponent {
1585
1858
  [class.ngx-field--readonly]="readonly"
1586
1859
  [class.ngx-field--masked]="masked"
1587
1860
  >
1588
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1861
+ <div class="ngx-field__label-row">
1862
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1863
+ @if (hint) {
1864
+ <span class="ngx-field__hint-wrap">
1865
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1866
+ <span
1867
+ class="ngx-field__hint"
1868
+ role="tooltip"
1869
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1870
+ [id]="domId('-hint')"
1871
+ >{{ hint }}</span
1872
+ >
1873
+ </span>
1874
+ }
1875
+ </div>
1589
1876
  @if (masked) {
1590
1877
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1591
1878
  maskedText
@@ -1601,6 +1888,7 @@ class PasswordFieldComponent {
1601
1888
  [type]="visible() ? 'text' : 'password'"
1602
1889
  autocomplete="current-password"
1603
1890
  [formControl]="$any(control)"
1891
+ [attr.aria-describedby]="describedBy()"
1604
1892
  [placeholder]="placeholder || '••••••••'"
1605
1893
  [attr.disabled]="field.disabled ? true : null"
1606
1894
  />
@@ -1614,7 +1902,9 @@ class PasswordFieldComponent {
1614
1902
  </button>
1615
1903
  </div>
1616
1904
  @if (errorMessage) {
1617
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1905
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1906
+ errorMessage
1907
+ }}</span>
1618
1908
  }
1619
1909
  }
1620
1910
  </div>
@@ -1635,7 +1925,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1635
1925
  [class.ngx-field--readonly]="readonly"
1636
1926
  [class.ngx-field--masked]="masked"
1637
1927
  >
1638
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1928
+ <div class="ngx-field__label-row">
1929
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1930
+ @if (hint) {
1931
+ <span class="ngx-field__hint-wrap">
1932
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
1933
+ <span
1934
+ class="ngx-field__hint"
1935
+ role="tooltip"
1936
+ [attr.data-testid]="'field-' + field.id + '-hint'"
1937
+ [id]="domId('-hint')"
1938
+ >{{ hint }}</span
1939
+ >
1940
+ </span>
1941
+ }
1942
+ </div>
1639
1943
  @if (masked) {
1640
1944
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1641
1945
  maskedText
@@ -1651,6 +1955,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1651
1955
  [type]="visible() ? 'text' : 'password'"
1652
1956
  autocomplete="current-password"
1653
1957
  [formControl]="$any(control)"
1958
+ [attr.aria-describedby]="describedBy()"
1654
1959
  [placeholder]="placeholder || '••••••••'"
1655
1960
  [attr.disabled]="field.disabled ? true : null"
1656
1961
  />
@@ -1664,7 +1969,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1664
1969
  </button>
1665
1970
  </div>
1666
1971
  @if (errorMessage) {
1667
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1972
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
1973
+ errorMessage
1974
+ }}</span>
1668
1975
  }
1669
1976
  }
1670
1977
  </div>
@@ -1710,10 +2017,18 @@ class CheckboxFieldComponent {
1710
2017
  * It rendered no error element at all, so a required checkbox left unticked told the user
1711
2018
  * nothing — and `validationMessages` could not reach a field that never displayed one.
1712
2019
  */
2020
+ /** Author help text, shown under the control and named by `aria-describedby`. */
2021
+ get hint() {
2022
+ return resolveLabel(this.field?.hint, this.language);
2023
+ }
2024
+ /** The ids of whatever is describing this control right now. */
2025
+ describedBy() {
2026
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
2027
+ }
1713
2028
  get errorMessage() {
1714
2029
  if (!this.control?.errors || !this.control.touched)
1715
2030
  return '';
1716
- return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
2031
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
1717
2032
  }
1718
2033
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: CheckboxFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1719
2034
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: CheckboxFieldComponent, isStandalone: true, selector: "ngx-checkbox-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1742,14 +2057,31 @@ class CheckboxFieldComponent {
1742
2057
  [attr.data-testid]="'field-' + field.id + '-input'"
1743
2058
  type="checkbox"
1744
2059
  [formControl]="$any(control)"
2060
+ [attr.aria-describedby]="describedBy()"
1745
2061
  [attr.disabled]="field.disabled ? true : null"
1746
2062
  />
1747
2063
  {{ label }}
2064
+ @if (hint) {
2065
+ <span class="ngx-field__hint-wrap">
2066
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2067
+ <span
2068
+ class="ngx-field__hint"
2069
+ role="tooltip"
2070
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2071
+ [id]="domId('-hint')"
2072
+ >{{ hint }}</span
2073
+ >
2074
+ </span>
2075
+ }
1748
2076
  </label>
1749
2077
  @if (errorMessage) {
1750
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1751
- errorMessage
1752
- }}</span>
2078
+ <span
2079
+ class="ngx-field__error"
2080
+ [attr.data-testid]="'field-' + field.id + '-error'"
2081
+ [id]="domId('-error')"
2082
+ role="alert"
2083
+ >{{ errorMessage }}</span
2084
+ >
1753
2085
  }
1754
2086
  }
1755
2087
  </div>
@@ -1788,14 +2120,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1788
2120
  [attr.data-testid]="'field-' + field.id + '-input'"
1789
2121
  type="checkbox"
1790
2122
  [formControl]="$any(control)"
2123
+ [attr.aria-describedby]="describedBy()"
1791
2124
  [attr.disabled]="field.disabled ? true : null"
1792
2125
  />
1793
2126
  {{ label }}
2127
+ @if (hint) {
2128
+ <span class="ngx-field__hint-wrap">
2129
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2130
+ <span
2131
+ class="ngx-field__hint"
2132
+ role="tooltip"
2133
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2134
+ [id]="domId('-hint')"
2135
+ >{{ hint }}</span
2136
+ >
2137
+ </span>
2138
+ }
1794
2139
  </label>
1795
2140
  @if (errorMessage) {
1796
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1797
- errorMessage
1798
- }}</span>
2141
+ <span
2142
+ class="ngx-field__error"
2143
+ [attr.data-testid]="'field-' + field.id + '-error'"
2144
+ [id]="domId('-error')"
2145
+ role="alert"
2146
+ >{{ errorMessage }}</span
2147
+ >
1799
2148
  }
1800
2149
  }
1801
2150
  </div>
@@ -1819,6 +2168,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1819
2168
  * toggle switch with Yes/No labels for clearer binary state communication.
1820
2169
  */
1821
2170
  class BooleanFieldComponent {
2171
+ /**
2172
+ * Unique to this component instance: an `array` renders the same field once per row, and a
2173
+ * DOM id may not repeat. See `field-dom-id.ts`.
2174
+ */
2175
+ instanceId = nextFieldInstanceId();
2176
+ domId(suffix = '') {
2177
+ return fieldDomId(this.field, this.instanceId, suffix);
2178
+ }
1822
2179
  /** Library chrome, overridable via UI_TEXT. */
1823
2180
  ui = inject(UiTextService);
1824
2181
  /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
@@ -1838,10 +2195,18 @@ class BooleanFieldComponent {
1838
2195
  * It rendered no error element at all, so a required checkbox left unticked told the user
1839
2196
  * nothing — and `validationMessages` could not reach a field that never displayed one.
1840
2197
  */
2198
+ /** Author help text, shown under the control and named by `aria-describedby`. */
2199
+ get hint() {
2200
+ return resolveLabel(this.field?.hint, this.language);
2201
+ }
2202
+ /** The ids of whatever is describing this control right now. */
2203
+ describedBy() {
2204
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
2205
+ }
1841
2206
  get errorMessage() {
1842
2207
  if (!this.control?.errors || !this.control.touched)
1843
2208
  return '';
1844
- return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
2209
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
1845
2210
  }
1846
2211
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BooleanFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1847
2212
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: BooleanFieldComponent, isStandalone: true, selector: "ngx-boolean-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1865,12 +2230,25 @@ class BooleanFieldComponent {
1865
2230
  } @else {
1866
2231
  <div class="ngx-field__toggle-wrap">
1867
2232
  <label class="ngx-field__toggle-label">{{ label }}</label>
2233
+ @if (hint) {
2234
+ <span class="ngx-field__hint-wrap">
2235
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2236
+ <span
2237
+ class="ngx-field__hint"
2238
+ role="tooltip"
2239
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2240
+ [id]="domId('-hint')"
2241
+ >{{ hint }}</span
2242
+ >
2243
+ </span>
2244
+ }
1868
2245
  <label class="ngx-field__toggle-switch" [class.ngx-field__toggle-switch--checked]="control.value">
1869
2246
  <input
1870
2247
  class="ngx-field__toggle-input"
1871
2248
  type="checkbox"
1872
2249
  role="switch"
1873
2250
  [formControl]="$any(control)"
2251
+ [attr.aria-describedby]="describedBy()"
1874
2252
  [attr.disabled]="field.disabled ? true : null"
1875
2253
  [attr.aria-checked]="control.value"
1876
2254
  />
@@ -1883,9 +2261,13 @@ class BooleanFieldComponent {
1883
2261
  </label>
1884
2262
  </div>
1885
2263
  @if (errorMessage) {
1886
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1887
- errorMessage
1888
- }}</span>
2264
+ <span
2265
+ class="ngx-field__error"
2266
+ [attr.data-testid]="'field-' + field.id + '-error'"
2267
+ [id]="domId('-error')"
2268
+ role="alert"
2269
+ >{{ errorMessage }}</span
2270
+ >
1889
2271
  }
1890
2272
  }
1891
2273
  </div>
@@ -1919,12 +2301,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1919
2301
  } @else {
1920
2302
  <div class="ngx-field__toggle-wrap">
1921
2303
  <label class="ngx-field__toggle-label">{{ label }}</label>
2304
+ @if (hint) {
2305
+ <span class="ngx-field__hint-wrap">
2306
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2307
+ <span
2308
+ class="ngx-field__hint"
2309
+ role="tooltip"
2310
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2311
+ [id]="domId('-hint')"
2312
+ >{{ hint }}</span
2313
+ >
2314
+ </span>
2315
+ }
1922
2316
  <label class="ngx-field__toggle-switch" [class.ngx-field__toggle-switch--checked]="control.value">
1923
2317
  <input
1924
2318
  class="ngx-field__toggle-input"
1925
2319
  type="checkbox"
1926
2320
  role="switch"
1927
2321
  [formControl]="$any(control)"
2322
+ [attr.aria-describedby]="describedBy()"
1928
2323
  [attr.disabled]="field.disabled ? true : null"
1929
2324
  [attr.aria-checked]="control.value"
1930
2325
  />
@@ -1937,9 +2332,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1937
2332
  </label>
1938
2333
  </div>
1939
2334
  @if (errorMessage) {
1940
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1941
- errorMessage
1942
- }}</span>
2335
+ <span
2336
+ class="ngx-field__error"
2337
+ [attr.data-testid]="'field-' + field.id + '-error'"
2338
+ [id]="domId('-error')"
2339
+ role="alert"
2340
+ >{{ errorMessage }}</span
2341
+ >
1943
2342
  }
1944
2343
  }
1945
2344
  </div>
@@ -1977,16 +2376,30 @@ class DateFieldComponent {
1977
2376
  get label() {
1978
2377
  return resolveLabel(this.field?.label, this.language);
1979
2378
  }
1980
- /** ISO 8601 date → locale display string (ONEHERMES convention: always UTC, display in user locale) */
2379
+ /**
2380
+ * ISO 8601 date → display string, through the formatter the host configured.
2381
+ *
2382
+ * This used to call `toLocaleDateString()` directly, which meant `setDateFormatters` — the
2383
+ * one seam for choosing how a date is rendered — reached the record summary and the `time`
2384
+ * field but not this one. A host that configured formatters got them almost everywhere,
2385
+ * and silently did not get them on the field type most likely to be the reason they
2386
+ * configured formatters at all.
2387
+ *
2388
+ * The default formatter is `toLocaleDateString()` with no locale, so an application that
2389
+ * has not called `setDateFormatters` renders exactly what it rendered before.
2390
+ */
1981
2391
  formatDate(value) {
1982
2392
  if (!value)
1983
2393
  return '—';
1984
2394
  // `new Date('nonsense')` does not throw and `toLocaleDateString()` returns the *string*
1985
2395
  // "Invalid Date", so the try/catch this replaced never fired and readers saw that text
1986
2396
  // instead of their data. Records outlive schemas — a field retyped from text to date can
1987
- // hold anything — so an unparseable value is shown as stored.
2397
+ // hold anything — so an unparseable value is shown as stored, and never reaches the
2398
+ // formatter.
1988
2399
  const parsed = new Date(value);
1989
- return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString();
2400
+ if (Number.isNaN(parsed.getTime()))
2401
+ return value;
2402
+ return formatDisplayValue('date', undefined, value, this.language);
1990
2403
  }
1991
2404
  /**
1992
2405
  * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
@@ -1994,18 +2407,18 @@ class DateFieldComponent {
1994
2407
  * "This field has an error", which made a documented, configurable feature work on three
1995
2408
  * of fifteen field types.
1996
2409
  */
2410
+ /** Author help text, shown under the control and named by `aria-describedby`. */
2411
+ get hint() {
2412
+ return resolveLabel(this.field?.hint, this.language);
2413
+ }
2414
+ /** The ids of whatever is describing this control right now. */
2415
+ describedBy() {
2416
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
2417
+ }
1997
2418
  get errorMessage() {
1998
2419
  if (!this.control?.errors || !this.control.touched)
1999
2420
  return '';
2000
- return this.messages.resolve(this.control.errors, this.language, [
2001
- 'required',
2002
- 'email',
2003
- 'min',
2004
- 'max',
2005
- 'minlength',
2006
- 'maxlength',
2007
- 'pattern',
2008
- ]);
2421
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2009
2422
  }
2010
2423
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2011
2424
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DateFieldComponent, isStandalone: true, selector: "ngx-date-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -2016,7 +2429,21 @@ class DateFieldComponent {
2016
2429
  [class.ngx-field--readonly]="readonly"
2017
2430
  [class.ngx-field--masked]="masked"
2018
2431
  >
2019
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2432
+ <div class="ngx-field__label-row">
2433
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2434
+ @if (hint) {
2435
+ <span class="ngx-field__hint-wrap">
2436
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2437
+ <span
2438
+ class="ngx-field__hint"
2439
+ role="tooltip"
2440
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2441
+ [id]="domId('-hint')"
2442
+ >{{ hint }}</span
2443
+ >
2444
+ </span>
2445
+ }
2446
+ </div>
2020
2447
  @if (masked) {
2021
2448
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2022
2449
  maskedText
@@ -2032,10 +2459,13 @@ class DateFieldComponent {
2032
2459
  [attr.data-testid]="'field-' + field.id + '-input'"
2033
2460
  type="date"
2034
2461
  [formControl]="$any(control)"
2462
+ [attr.aria-describedby]="describedBy()"
2035
2463
  [attr.disabled]="field.disabled ? true : null"
2036
2464
  />
2037
2465
  @if (errorMessage) {
2038
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2466
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
2467
+ errorMessage
2468
+ }}</span>
2039
2469
  }
2040
2470
  }
2041
2471
  </div>
@@ -2056,7 +2486,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2056
2486
  [class.ngx-field--readonly]="readonly"
2057
2487
  [class.ngx-field--masked]="masked"
2058
2488
  >
2059
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2489
+ <div class="ngx-field__label-row">
2490
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2491
+ @if (hint) {
2492
+ <span class="ngx-field__hint-wrap">
2493
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2494
+ <span
2495
+ class="ngx-field__hint"
2496
+ role="tooltip"
2497
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2498
+ [id]="domId('-hint')"
2499
+ >{{ hint }}</span
2500
+ >
2501
+ </span>
2502
+ }
2503
+ </div>
2060
2504
  @if (masked) {
2061
2505
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2062
2506
  maskedText
@@ -2072,10 +2516,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2072
2516
  [attr.data-testid]="'field-' + field.id + '-input'"
2073
2517
  type="date"
2074
2518
  [formControl]="$any(control)"
2519
+ [attr.aria-describedby]="describedBy()"
2075
2520
  [attr.disabled]="field.disabled ? true : null"
2076
2521
  />
2077
2522
  @if (errorMessage) {
2078
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2523
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
2524
+ errorMessage
2525
+ }}</span>
2079
2526
  }
2080
2527
  }
2081
2528
  </div>
@@ -2162,13 +2609,23 @@ class DateTimeFieldComponent {
2162
2609
  this.control.markAsTouched();
2163
2610
  }
2164
2611
  /**
2165
- * Displays date *and* time. `DateFieldComponent` used `toLocaleDateString()` here, so a
2166
- * datetime read back through the field showed no time while the same value in the record
2167
- * summary — which goes through core's `formatDisplayValue` — showed it.
2612
+ * Displays date *and* time, through the formatter the host configured.
2613
+ *
2614
+ * Two rounds of the same fault. First this called `toLocaleDateString()`, so a datetime
2615
+ * read back through the field showed no time while the record summary showed it. Then it
2616
+ * called `toLocaleString()` directly, which fixed the time but kept the field outside
2617
+ * `setDateFormatters` — so a host that configured formatters still saw them apply to the
2618
+ * summary and not to the field. Going through `formatDisplayValue` is what makes the two
2619
+ * paths agree by construction rather than by matching literals.
2620
+ *
2621
+ * `parseStored` accepts shapes `new Date(raw)` alone does not, so the *parsed* instant is
2622
+ * handed on as ISO rather than the raw value.
2168
2623
  */
2169
2624
  formatDateTime(value) {
2170
2625
  const d = parseStored(value);
2171
- return d ? d.toLocaleString() : '—';
2626
+ if (!d)
2627
+ return '—';
2628
+ return formatDisplayValue('datetime', undefined, d.toISOString(), this.language);
2172
2629
  }
2173
2630
  /**
2174
2631
  * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
@@ -2176,18 +2633,18 @@ class DateTimeFieldComponent {
2176
2633
  * "This field has an error", which made a documented, configurable feature work on three
2177
2634
  * of fifteen field types.
2178
2635
  */
2636
+ /** Author help text, shown under the control and named by `aria-describedby`. */
2637
+ get hint() {
2638
+ return resolveLabel(this.field?.hint, this.language);
2639
+ }
2640
+ /** The ids of whatever is describing this control right now. */
2641
+ describedBy() {
2642
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
2643
+ }
2179
2644
  get errorMessage() {
2180
2645
  if (!this.control?.errors || !this.control.touched)
2181
2646
  return '';
2182
- return this.messages.resolve(this.control.errors, this.language, [
2183
- 'required',
2184
- 'email',
2185
- 'min',
2186
- 'max',
2187
- 'minlength',
2188
- 'maxlength',
2189
- 'pattern',
2190
- ]);
2647
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2191
2648
  }
2192
2649
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateTimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2193
2650
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DateTimeFieldComponent, isStandalone: true, selector: "ngx-date-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -2198,7 +2655,21 @@ class DateTimeFieldComponent {
2198
2655
  [class.ngx-field--readonly]="readonly"
2199
2656
  [class.ngx-field--masked]="masked"
2200
2657
  >
2201
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2658
+ <div class="ngx-field__label-row">
2659
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2660
+ @if (hint) {
2661
+ <span class="ngx-field__hint-wrap">
2662
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2663
+ <span
2664
+ class="ngx-field__hint"
2665
+ role="tooltip"
2666
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2667
+ [id]="domId('-hint')"
2668
+ >{{ hint }}</span
2669
+ >
2670
+ </span>
2671
+ }
2672
+ </div>
2202
2673
  @if (masked) {
2203
2674
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2204
2675
  maskedText
@@ -2216,9 +2687,12 @@ class DateTimeFieldComponent {
2216
2687
  [value]="inputValue"
2217
2688
  (change)="onInput($any($event.target).value)"
2218
2689
  [attr.disabled]="field.disabled ? true : null"
2690
+ [attr.aria-describedby]="describedBy()"
2219
2691
  />
2220
2692
  @if (errorMessage) {
2221
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2693
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
2694
+ errorMessage
2695
+ }}</span>
2222
2696
  }
2223
2697
  }
2224
2698
  </div>
@@ -2239,7 +2713,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2239
2713
  [class.ngx-field--readonly]="readonly"
2240
2714
  [class.ngx-field--masked]="masked"
2241
2715
  >
2242
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2716
+ <div class="ngx-field__label-row">
2717
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2718
+ @if (hint) {
2719
+ <span class="ngx-field__hint-wrap">
2720
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2721
+ <span
2722
+ class="ngx-field__hint"
2723
+ role="tooltip"
2724
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2725
+ [id]="domId('-hint')"
2726
+ >{{ hint }}</span
2727
+ >
2728
+ </span>
2729
+ }
2730
+ </div>
2243
2731
  @if (masked) {
2244
2732
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2245
2733
  maskedText
@@ -2257,9 +2745,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2257
2745
  [value]="inputValue"
2258
2746
  (change)="onInput($any($event.target).value)"
2259
2747
  [attr.disabled]="field.disabled ? true : null"
2748
+ [attr.aria-describedby]="describedBy()"
2260
2749
  />
2261
2750
  @if (errorMessage) {
2262
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2751
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
2752
+ errorMessage
2753
+ }}</span>
2263
2754
  }
2264
2755
  }
2265
2756
  </div>
@@ -2319,18 +2810,18 @@ class TimeFieldComponent {
2319
2810
  * "This field has an error", which made a documented, configurable feature work on three
2320
2811
  * of fifteen field types.
2321
2812
  */
2813
+ /** Author help text, shown under the control and named by `aria-describedby`. */
2814
+ get hint() {
2815
+ return resolveLabel(this.field?.hint, this.language);
2816
+ }
2817
+ /** The ids of whatever is describing this control right now. */
2818
+ describedBy() {
2819
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
2820
+ }
2322
2821
  get errorMessage() {
2323
2822
  if (!this.control?.errors || !this.control.touched)
2324
2823
  return '';
2325
- return this.messages.resolve(this.control.errors, this.language, [
2326
- 'required',
2327
- 'email',
2328
- 'min',
2329
- 'max',
2330
- 'minlength',
2331
- 'maxlength',
2332
- 'pattern',
2333
- ]);
2824
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2334
2825
  }
2335
2826
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2336
2827
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: TimeFieldComponent, isStandalone: true, selector: "ngx-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -2341,7 +2832,21 @@ class TimeFieldComponent {
2341
2832
  [class.ngx-field--readonly]="readonly"
2342
2833
  [class.ngx-field--masked]="masked"
2343
2834
  >
2344
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2835
+ <div class="ngx-field__label-row">
2836
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2837
+ @if (hint) {
2838
+ <span class="ngx-field__hint-wrap">
2839
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2840
+ <span
2841
+ class="ngx-field__hint"
2842
+ role="tooltip"
2843
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2844
+ [id]="domId('-hint')"
2845
+ >{{ hint }}</span
2846
+ >
2847
+ </span>
2848
+ }
2849
+ </div>
2345
2850
  @if (masked) {
2346
2851
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2347
2852
  maskedText
@@ -2357,10 +2862,13 @@ class TimeFieldComponent {
2357
2862
  [attr.data-testid]="'field-' + field.id + '-input'"
2358
2863
  type="time"
2359
2864
  [formControl]="$any(control)"
2865
+ [attr.aria-describedby]="describedBy()"
2360
2866
  [attr.disabled]="field.disabled ? true : null"
2361
2867
  />
2362
2868
  @if (errorMessage) {
2363
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2869
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
2870
+ errorMessage
2871
+ }}</span>
2364
2872
  }
2365
2873
  }
2366
2874
  </div>
@@ -2381,7 +2889,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2381
2889
  [class.ngx-field--readonly]="readonly"
2382
2890
  [class.ngx-field--masked]="masked"
2383
2891
  >
2384
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2892
+ <div class="ngx-field__label-row">
2893
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2894
+ @if (hint) {
2895
+ <span class="ngx-field__hint-wrap">
2896
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
2897
+ <span
2898
+ class="ngx-field__hint"
2899
+ role="tooltip"
2900
+ [attr.data-testid]="'field-' + field.id + '-hint'"
2901
+ [id]="domId('-hint')"
2902
+ >{{ hint }}</span
2903
+ >
2904
+ </span>
2905
+ }
2906
+ </div>
2385
2907
  @if (masked) {
2386
2908
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2387
2909
  maskedText
@@ -2397,10 +2919,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2397
2919
  [attr.data-testid]="'field-' + field.id + '-input'"
2398
2920
  type="time"
2399
2921
  [formControl]="$any(control)"
2922
+ [attr.aria-describedby]="describedBy()"
2400
2923
  [attr.disabled]="field.disabled ? true : null"
2401
2924
  />
2402
2925
  @if (errorMessage) {
2403
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2926
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
2927
+ errorMessage
2928
+ }}</span>
2404
2929
  }
2405
2930
  }
2406
2931
  </div>
@@ -2494,18 +3019,18 @@ class MonthYearFieldComponent {
2494
3019
  * "This field has an error", which made a documented, configurable feature work on three
2495
3020
  * of fifteen field types.
2496
3021
  */
3022
+ /** Author help text, shown under the control and named by `aria-describedby`. */
3023
+ get hint() {
3024
+ return resolveLabel(this.field?.hint, this.language);
3025
+ }
3026
+ /** The ids of whatever is describing this control right now. */
3027
+ describedBy() {
3028
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
3029
+ }
2497
3030
  get errorMessage() {
2498
3031
  if (!this.control?.errors || !this.control.touched)
2499
3032
  return '';
2500
- return this.messages.resolve(this.control.errors, this.language, [
2501
- 'required',
2502
- 'email',
2503
- 'min',
2504
- 'max',
2505
- 'minlength',
2506
- 'maxlength',
2507
- 'pattern',
2508
- ]);
3033
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2509
3034
  }
2510
3035
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MonthYearFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2511
3036
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: MonthYearFieldComponent, isStandalone: true, selector: "ngx-month-year-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -2522,7 +3047,21 @@ class MonthYearFieldComponent {
2522
3047
  clicked. Each select then carries its own name below, because "Billing Cycle" alone
2523
3048
  does not say which half of the pair the screen reader has landed on.
2524
3049
  -->
2525
- <label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
3050
+ <div class="ngx-field__label-row">
3051
+ <label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
3052
+ @if (hint) {
3053
+ <span class="ngx-field__hint-wrap">
3054
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
3055
+ <span
3056
+ class="ngx-field__hint"
3057
+ role="tooltip"
3058
+ [attr.data-testid]="'field-' + field.id + '-hint'"
3059
+ [id]="domId('-hint')"
3060
+ >{{ hint }}</span
3061
+ >
3062
+ </span>
3063
+ }
3064
+ </div>
2526
3065
  @if (masked) {
2527
3066
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2528
3067
  maskedText
@@ -2541,6 +3080,7 @@ class MonthYearFieldComponent {
2541
3080
  [value]="selectedMonth"
2542
3081
  (change)="onMonthChange($any($event.target).value)"
2543
3082
  [attr.disabled]="field.disabled ? true : null"
3083
+ [attr.aria-describedby]="describedBy()"
2544
3084
  >
2545
3085
  <option value="">{{ ui.text('month', language) }}</option>
2546
3086
  @for (m of months; track m.value) {
@@ -2555,6 +3095,7 @@ class MonthYearFieldComponent {
2555
3095
  [value]="selectedYear"
2556
3096
  (change)="onYearChange($any($event.target).value)"
2557
3097
  [attr.disabled]="field.disabled ? true : null"
3098
+ [attr.aria-describedby]="describedBy()"
2558
3099
  >
2559
3100
  <option value="">{{ ui.text('year', language) }}</option>
2560
3101
  @for (y of years; track y) {
@@ -2563,7 +3104,9 @@ class MonthYearFieldComponent {
2563
3104
  </select>
2564
3105
  </div>
2565
3106
  @if (errorMessage) {
2566
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
3107
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
3108
+ errorMessage
3109
+ }}</span>
2567
3110
  }
2568
3111
  }
2569
3112
  </div>
@@ -2590,7 +3133,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2590
3133
  clicked. Each select then carries its own name below, because "Billing Cycle" alone
2591
3134
  does not say which half of the pair the screen reader has landed on.
2592
3135
  -->
2593
- <label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
3136
+ <div class="ngx-field__label-row">
3137
+ <label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
3138
+ @if (hint) {
3139
+ <span class="ngx-field__hint-wrap">
3140
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
3141
+ <span
3142
+ class="ngx-field__hint"
3143
+ role="tooltip"
3144
+ [attr.data-testid]="'field-' + field.id + '-hint'"
3145
+ [id]="domId('-hint')"
3146
+ >{{ hint }}</span
3147
+ >
3148
+ </span>
3149
+ }
3150
+ </div>
2594
3151
  @if (masked) {
2595
3152
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2596
3153
  maskedText
@@ -2609,6 +3166,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2609
3166
  [value]="selectedMonth"
2610
3167
  (change)="onMonthChange($any($event.target).value)"
2611
3168
  [attr.disabled]="field.disabled ? true : null"
3169
+ [attr.aria-describedby]="describedBy()"
2612
3170
  >
2613
3171
  <option value="">{{ ui.text('month', language) }}</option>
2614
3172
  @for (m of months; track m.value) {
@@ -2623,6 +3181,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2623
3181
  [value]="selectedYear"
2624
3182
  (change)="onYearChange($any($event.target).value)"
2625
3183
  [attr.disabled]="field.disabled ? true : null"
3184
+ [attr.aria-describedby]="describedBy()"
2626
3185
  >
2627
3186
  <option value="">{{ ui.text('year', language) }}</option>
2628
3187
  @for (y of years; track y) {
@@ -2631,7 +3190,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2631
3190
  </select>
2632
3191
  </div>
2633
3192
  @if (errorMessage) {
2634
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
3193
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
3194
+ errorMessage
3195
+ }}</span>
2635
3196
  }
2636
3197
  }
2637
3198
  </div>
@@ -2961,12 +3522,20 @@ class DropdownFieldComponent {
2961
3522
  return cached;
2962
3523
  return typeof value === 'object' ? resolveLabel(value, this.language) : String(value ?? '—');
2963
3524
  }
3525
+ /** Author help text, shown under the control and named by `aria-describedby`. */
3526
+ get hint() {
3527
+ return resolveLabel(this.field?.hint, this.language);
3528
+ }
3529
+ /** The ids of whatever is describing this control right now. */
3530
+ describedBy() {
3531
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
3532
+ }
2964
3533
  get errorMessage() {
2965
3534
  if (!this.control || !this.control.errors || !this.control.touched)
2966
3535
  return '';
2967
3536
  // `requiredSelection` rather than `required`: "Please select an option" reads better on a
2968
3537
  // dropdown than "This field is required", and both stay independently overridable.
2969
- return this.messages.resolve(this.control.errors, this.language, [['required', 'requiredSelection']], 'invalidSelection');
3538
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2970
3539
  }
2971
3540
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DropdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2972
3541
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DropdownFieldComponent, isStandalone: true, selector: "ngx-dropdown-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -2978,12 +3547,26 @@ class DropdownFieldComponent {
2978
3547
  [class.ngx-field--masked]="masked"
2979
3548
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
2980
3549
  >
2981
- <label class="ngx-field__label" [attr.for]="domId()">
2982
- {{ label }}
2983
- @if (field.validators?.required) {
2984
- <span class="ngx-field__req">*</span>
3550
+ <div class="ngx-field__label-row">
3551
+ <label class="ngx-field__label" [attr.for]="domId()">
3552
+ {{ label }}
3553
+ @if (field.validators?.required) {
3554
+ <span class="ngx-field__req">*</span>
3555
+ }
3556
+ </label>
3557
+ @if (hint) {
3558
+ <span class="ngx-field__hint-wrap">
3559
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
3560
+ <span
3561
+ class="ngx-field__hint"
3562
+ role="tooltip"
3563
+ [attr.data-testid]="'field-' + field.id + '-hint'"
3564
+ [id]="domId('-hint')"
3565
+ >{{ hint }}</span
3566
+ >
3567
+ </span>
2985
3568
  }
2986
- </label>
3569
+ </div>
2987
3570
  @if (masked) {
2988
3571
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2989
3572
  maskedText
@@ -3001,7 +3584,7 @@ class DropdownFieldComponent {
3001
3584
  [compareWith]="compareFn"
3002
3585
  [attr.disabled]="field.disabled ? true : null"
3003
3586
  [attr.aria-invalid]="control.invalid && control.touched"
3004
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
3587
+ [attr.aria-describedby]="describedBy()"
3005
3588
  >
3006
3589
  <option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
3007
3590
  @for (option of options(); track getOptLabel(option)) {
@@ -3041,12 +3624,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3041
3624
  [class.ngx-field--masked]="masked"
3042
3625
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
3043
3626
  >
3044
- <label class="ngx-field__label" [attr.for]="domId()">
3045
- {{ label }}
3046
- @if (field.validators?.required) {
3047
- <span class="ngx-field__req">*</span>
3627
+ <div class="ngx-field__label-row">
3628
+ <label class="ngx-field__label" [attr.for]="domId()">
3629
+ {{ label }}
3630
+ @if (field.validators?.required) {
3631
+ <span class="ngx-field__req">*</span>
3632
+ }
3633
+ </label>
3634
+ @if (hint) {
3635
+ <span class="ngx-field__hint-wrap">
3636
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
3637
+ <span
3638
+ class="ngx-field__hint"
3639
+ role="tooltip"
3640
+ [attr.data-testid]="'field-' + field.id + '-hint'"
3641
+ [id]="domId('-hint')"
3642
+ >{{ hint }}</span
3643
+ >
3644
+ </span>
3048
3645
  }
3049
- </label>
3646
+ </div>
3050
3647
  @if (masked) {
3051
3648
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3052
3649
  maskedText
@@ -3064,7 +3661,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3064
3661
  [compareWith]="compareFn"
3065
3662
  [attr.disabled]="field.disabled ? true : null"
3066
3663
  [attr.aria-invalid]="control.invalid && control.touched"
3067
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
3664
+ [attr.aria-describedby]="describedBy()"
3068
3665
  >
3069
3666
  <option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
3070
3667
  @for (option of options(); track getOptLabel(option)) {
@@ -3189,18 +3786,18 @@ class RadioFieldComponent {
3189
3786
  * "This field has an error", which made a documented, configurable feature work on three
3190
3787
  * of fifteen field types.
3191
3788
  */
3789
+ /** Author help text, shown under the control and named by `aria-describedby`. */
3790
+ get hint() {
3791
+ return resolveLabel(this.field?.hint, this.language);
3792
+ }
3793
+ /** The ids of whatever is describing this control right now. */
3794
+ describedBy() {
3795
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
3796
+ }
3192
3797
  get errorMessage() {
3193
3798
  if (!this.control?.errors || !this.control.touched)
3194
3799
  return '';
3195
- return this.messages.resolve(this.control.errors, this.language, [
3196
- 'required',
3197
- 'email',
3198
- 'min',
3199
- 'max',
3200
- 'minlength',
3201
- 'maxlength',
3202
- 'pattern',
3203
- ]);
3800
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
3204
3801
  }
3205
3802
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RadioFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3206
3803
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: RadioFieldComponent, isStandalone: true, selector: "ngx-radio-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -3212,7 +3809,25 @@ class RadioFieldComponent {
3212
3809
  [class.ngx-field--masked]="masked"
3213
3810
  >
3214
3811
  <fieldset class="ngx-field__fieldset">
3215
- <legend class="ngx-field__label">{{ label }}</legend>
3812
+ <legend class="ngx-field__label">
3813
+ {{ label }}
3814
+ @if (hint) {
3815
+ <!--
3816
+ Inside the legend rather than beside it: a fieldset allows exactly one legend
3817
+ and it must be the first child, so a sibling row would be invalid here.
3818
+ -->
3819
+ <span class="ngx-field__hint-wrap">
3820
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
3821
+ <span
3822
+ class="ngx-field__hint"
3823
+ role="tooltip"
3824
+ [attr.data-testid]="'field-' + field.id + '-hint'"
3825
+ [id]="domId('-hint')"
3826
+ >{{ hint }}</span
3827
+ >
3828
+ </span>
3829
+ }
3830
+ </legend>
3216
3831
  @if (masked) {
3217
3832
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3218
3833
  maskedText
@@ -3229,6 +3844,7 @@ class RadioFieldComponent {
3229
3844
  type="radio"
3230
3845
  class="ngx-field__radio-input"
3231
3846
  [formControl]="$any(control)"
3847
+ [attr.aria-describedby]="describedBy()"
3232
3848
  [value]="getOptStoredVal(option)"
3233
3849
  [attr.disabled]="field.disabled ? true : null"
3234
3850
  />
@@ -3237,7 +3853,9 @@ class RadioFieldComponent {
3237
3853
  }
3238
3854
  </div>
3239
3855
  @if (errorMessage) {
3240
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
3856
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
3857
+ errorMessage
3858
+ }}</span>
3241
3859
  }
3242
3860
  }
3243
3861
  </fieldset>
@@ -3260,7 +3878,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3260
3878
  [class.ngx-field--masked]="masked"
3261
3879
  >
3262
3880
  <fieldset class="ngx-field__fieldset">
3263
- <legend class="ngx-field__label">{{ label }}</legend>
3881
+ <legend class="ngx-field__label">
3882
+ {{ label }}
3883
+ @if (hint) {
3884
+ <!--
3885
+ Inside the legend rather than beside it: a fieldset allows exactly one legend
3886
+ and it must be the first child, so a sibling row would be invalid here.
3887
+ -->
3888
+ <span class="ngx-field__hint-wrap">
3889
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
3890
+ <span
3891
+ class="ngx-field__hint"
3892
+ role="tooltip"
3893
+ [attr.data-testid]="'field-' + field.id + '-hint'"
3894
+ [id]="domId('-hint')"
3895
+ >{{ hint }}</span
3896
+ >
3897
+ </span>
3898
+ }
3899
+ </legend>
3264
3900
  @if (masked) {
3265
3901
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3266
3902
  maskedText
@@ -3277,6 +3913,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3277
3913
  type="radio"
3278
3914
  class="ngx-field__radio-input"
3279
3915
  [formControl]="$any(control)"
3916
+ [attr.aria-describedby]="describedBy()"
3280
3917
  [value]="getOptStoredVal(option)"
3281
3918
  [attr.disabled]="field.disabled ? true : null"
3282
3919
  />
@@ -3285,7 +3922,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3285
3922
  }
3286
3923
  </div>
3287
3924
  @if (errorMessage) {
3288
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
3925
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
3926
+ errorMessage
3927
+ }}</span>
3289
3928
  }
3290
3929
  }
3291
3930
  </fieldset>
@@ -3375,18 +4014,18 @@ class MultiSelectFieldComponent {
3375
4014
  * "This field has an error", which made a documented, configurable feature work on three
3376
4015
  * of fifteen field types.
3377
4016
  */
4017
+ /** Author help text, shown under the control and named by `aria-describedby`. */
4018
+ get hint() {
4019
+ return resolveLabel(this.field?.hint, this.language);
4020
+ }
4021
+ /** The ids of whatever is describing this control right now. */
4022
+ describedBy() {
4023
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
4024
+ }
3378
4025
  get errorMessage() {
3379
4026
  if (!this.control?.errors || !this.control.touched)
3380
4027
  return '';
3381
- return this.messages.resolve(this.control.errors, this.language, [
3382
- 'required',
3383
- 'email',
3384
- 'min',
3385
- 'max',
3386
- 'minlength',
3387
- 'maxlength',
3388
- 'pattern',
3389
- ]);
4028
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
3390
4029
  }
3391
4030
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiSelectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3392
4031
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: MultiSelectFieldComponent, isStandalone: true, selector: "ngx-multi-select-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -3397,7 +4036,21 @@ class MultiSelectFieldComponent {
3397
4036
  [class.ngx-field--readonly]="readonly"
3398
4037
  [class.ngx-field--masked]="masked"
3399
4038
  >
3400
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4039
+ <div class="ngx-field__label-row">
4040
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4041
+ @if (hint) {
4042
+ <span class="ngx-field__hint-wrap">
4043
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
4044
+ <span
4045
+ class="ngx-field__hint"
4046
+ role="tooltip"
4047
+ [attr.data-testid]="'field-' + field.id + '-hint'"
4048
+ [id]="domId('-hint')"
4049
+ >{{ hint }}</span
4050
+ >
4051
+ </span>
4052
+ }
4053
+ </div>
3401
4054
  @if (masked) {
3402
4055
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3403
4056
  maskedText
@@ -3412,6 +4065,7 @@ class MultiSelectFieldComponent {
3412
4065
  class="ngx-field__input"
3413
4066
  [attr.data-testid]="'field-' + field.id + '-input'"
3414
4067
  [formControl]="$any(control)"
4068
+ [attr.aria-describedby]="describedBy()"
3415
4069
  [compareWith]="compareFn"
3416
4070
  [attr.disabled]="field.disabled ? true : null"
3417
4071
  multiple
@@ -3426,7 +4080,9 @@ class MultiSelectFieldComponent {
3426
4080
  }
3427
4081
  </select>
3428
4082
  @if (errorMessage) {
3429
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
4083
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
4084
+ errorMessage
4085
+ }}</span>
3430
4086
  }
3431
4087
  }
3432
4088
  </div>
@@ -3447,7 +4103,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3447
4103
  [class.ngx-field--readonly]="readonly"
3448
4104
  [class.ngx-field--masked]="masked"
3449
4105
  >
3450
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4106
+ <div class="ngx-field__label-row">
4107
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4108
+ @if (hint) {
4109
+ <span class="ngx-field__hint-wrap">
4110
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
4111
+ <span
4112
+ class="ngx-field__hint"
4113
+ role="tooltip"
4114
+ [attr.data-testid]="'field-' + field.id + '-hint'"
4115
+ [id]="domId('-hint')"
4116
+ >{{ hint }}</span
4117
+ >
4118
+ </span>
4119
+ }
4120
+ </div>
3451
4121
  @if (masked) {
3452
4122
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3453
4123
  maskedText
@@ -3462,6 +4132,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3462
4132
  class="ngx-field__input"
3463
4133
  [attr.data-testid]="'field-' + field.id + '-input'"
3464
4134
  [formControl]="$any(control)"
4135
+ [attr.aria-describedby]="describedBy()"
3465
4136
  [compareWith]="compareFn"
3466
4137
  [attr.disabled]="field.disabled ? true : null"
3467
4138
  multiple
@@ -3476,7 +4147,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3476
4147
  }
3477
4148
  </select>
3478
4149
  @if (errorMessage) {
3479
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
4150
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
4151
+ errorMessage
4152
+ }}</span>
3480
4153
  }
3481
4154
  }
3482
4155
  </div>
@@ -3949,18 +4622,18 @@ class EntityRefFieldComponent {
3949
4622
  * "This field has an error", which made a documented, configurable feature work on three
3950
4623
  * of fifteen field types.
3951
4624
  */
4625
+ /** Author help text, shown under the control and named by `aria-describedby`. */
4626
+ get hint() {
4627
+ return resolveLabel(this.field?.hint, this.language);
4628
+ }
4629
+ /** The ids of whatever is describing this control right now. */
4630
+ describedBy() {
4631
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
4632
+ }
3952
4633
  get errorMessage() {
3953
4634
  if (!this.control?.errors || !this.control.touched)
3954
4635
  return '';
3955
- return this.messages.resolve(this.control.errors, this.language, [
3956
- 'required',
3957
- 'email',
3958
- 'min',
3959
- 'max',
3960
- 'minlength',
3961
- 'maxlength',
3962
- 'pattern',
3963
- ]);
4636
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
3964
4637
  }
3965
4638
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityRefFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3966
4639
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EntityRefFieldComponent, isStandalone: true, selector: "ngx-entity-ref-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -3971,7 +4644,21 @@ class EntityRefFieldComponent {
3971
4644
  [class.ngx-field--readonly]="readonly"
3972
4645
  [class.ngx-field--masked]="masked"
3973
4646
  >
3974
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4647
+ <div class="ngx-field__label-row">
4648
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4649
+ @if (hint) {
4650
+ <span class="ngx-field__hint-wrap">
4651
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
4652
+ <span
4653
+ class="ngx-field__hint"
4654
+ role="tooltip"
4655
+ [attr.data-testid]="'field-' + field.id + '-hint'"
4656
+ [id]="domId('-hint')"
4657
+ >{{ hint }}</span
4658
+ >
4659
+ </span>
4660
+ }
4661
+ </div>
3975
4662
  @if (masked) {
3976
4663
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3977
4664
  maskedText
@@ -3991,6 +4678,7 @@ class EntityRefFieldComponent {
3991
4678
  [attr.data-testid]="'field-' + field.id + '-input'"
3992
4679
  [id]="domId()"
3993
4680
  [formControl]="$any(control)"
4681
+ [attr.aria-describedby]="describedBy()"
3994
4682
  [attr.disabled]="field.disabled ? true : null"
3995
4683
  >
3996
4684
  <option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
@@ -4005,7 +4693,9 @@ class EntityRefFieldComponent {
4005
4693
  }
4006
4694
  }
4007
4695
  @if (errorMessage) {
4008
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
4696
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
4697
+ errorMessage
4698
+ }}</span>
4009
4699
  }
4010
4700
  }
4011
4701
  </div>
@@ -4026,7 +4716,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4026
4716
  [class.ngx-field--readonly]="readonly"
4027
4717
  [class.ngx-field--masked]="masked"
4028
4718
  >
4029
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4719
+ <div class="ngx-field__label-row">
4720
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
4721
+ @if (hint) {
4722
+ <span class="ngx-field__hint-wrap">
4723
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
4724
+ <span
4725
+ class="ngx-field__hint"
4726
+ role="tooltip"
4727
+ [attr.data-testid]="'field-' + field.id + '-hint'"
4728
+ [id]="domId('-hint')"
4729
+ >{{ hint }}</span
4730
+ >
4731
+ </span>
4732
+ }
4733
+ </div>
4030
4734
  @if (masked) {
4031
4735
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4032
4736
  maskedText
@@ -4046,6 +4750,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4046
4750
  [attr.data-testid]="'field-' + field.id + '-input'"
4047
4751
  [id]="domId()"
4048
4752
  [formControl]="$any(control)"
4753
+ [attr.aria-describedby]="describedBy()"
4049
4754
  [attr.disabled]="field.disabled ? true : null"
4050
4755
  >
4051
4756
  <option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
@@ -4060,7 +4765,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4060
4765
  }
4061
4766
  }
4062
4767
  @if (errorMessage) {
4063
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
4768
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="domId('-error')">{{
4769
+ errorMessage
4770
+ }}</span>
4064
4771
  }
4065
4772
  }
4066
4773
  </div>
@@ -4330,6 +5037,10 @@ class GroupFieldComponent {
4330
5037
  readonly = false;
4331
5038
  masked = false;
4332
5039
  userRoles = [];
5040
+ /** Author help text for the section as a whole. */
5041
+ get hint() {
5042
+ return resolveLabel(this.field?.hint, this.language);
5043
+ }
4333
5044
  get label() {
4334
5045
  return resolveLabel(this.field?.label, this.language);
4335
5046
  }
@@ -4339,11 +5050,27 @@ class GroupFieldComponent {
4339
5050
  }
4340
5051
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: GroupFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4341
5052
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: GroupFieldComponent, isStandalone: true, selector: "ngx-group-field", inputs: { field: "field", control: "control", config: "config", language: "language", readonly: "readonly", masked: "masked", userRoles: "userRoles" }, ngImport: i0, template: `
4342
- <fieldset class="ngx-field ngx-field--group"
4343
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
4344
- <legend class="ngx-field__legend">{{ label }}</legend>
5053
+ <fieldset
5054
+ class="ngx-field ngx-field--group"
5055
+ [attr.data-testid]="'field-' + field.id"
5056
+ [attr.data-field-type]="field.type"
5057
+ [class.ngx-field--readonly]="readonly"
5058
+ [class.ngx-field--masked]="masked"
5059
+ >
5060
+ <legend class="ngx-field__legend">
5061
+ {{ label }}
5062
+ @if (hint) {
5063
+ <!-- A group's hint describes the whole section, so it hangs off the section's name. -->
5064
+ <span class="ngx-field__hint-wrap">
5065
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5066
+ <span class="ngx-field__hint" role="tooltip" [attr.data-testid]="'field-' + field.id + '-hint'">{{ hint }}</span>
5067
+ </span>
5068
+ }
5069
+ </legend>
4345
5070
  @if (masked) {
4346
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{ maskedText }}</span>
5071
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5072
+ maskedText
5073
+ }}</span>
4347
5074
  } @else {
4348
5075
  <div class="ngx-field__group-children">
4349
5076
  @for (child of field.children ?? []; track child.id) {
@@ -4366,11 +5093,27 @@ class GroupFieldComponent {
4366
5093
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: GroupFieldComponent, decorators: [{
4367
5094
  type: Component,
4368
5095
  args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
4369
- <fieldset class="ngx-field ngx-field--group"
4370
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
4371
- <legend class="ngx-field__legend">{{ label }}</legend>
5096
+ <fieldset
5097
+ class="ngx-field ngx-field--group"
5098
+ [attr.data-testid]="'field-' + field.id"
5099
+ [attr.data-field-type]="field.type"
5100
+ [class.ngx-field--readonly]="readonly"
5101
+ [class.ngx-field--masked]="masked"
5102
+ >
5103
+ <legend class="ngx-field__legend">
5104
+ {{ label }}
5105
+ @if (hint) {
5106
+ <!-- A group's hint describes the whole section, so it hangs off the section's name. -->
5107
+ <span class="ngx-field__hint-wrap">
5108
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5109
+ <span class="ngx-field__hint" role="tooltip" [attr.data-testid]="'field-' + field.id + '-hint'">{{ hint }}</span>
5110
+ </span>
5111
+ }
5112
+ </legend>
4372
5113
  @if (masked) {
4373
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{ maskedText }}</span>
5114
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5115
+ maskedText
5116
+ }}</span>
4374
5117
  } @else {
4375
5118
  <div class="ngx-field__group-children">
4376
5119
  @for (child of field.children ?? []; track child.id) {
@@ -4521,6 +5264,10 @@ class ArrayFieldComponent {
4521
5264
  userRoles = [];
4522
5265
  fb = inject(FormBuilder);
4523
5266
  validatorRegistry = inject(ValidatorRegistryService);
5267
+ /** Author help text for the section as a whole. */
5268
+ get hint() {
5269
+ return resolveLabel(this.field?.hint, this.language);
5270
+ }
4524
5271
  get label() {
4525
5272
  return resolveLabel(this.field?.label, this.language);
4526
5273
  }
@@ -4554,7 +5301,18 @@ class ArrayFieldComponent {
4554
5301
  [class.ngx-field--masked]="masked"
4555
5302
  >
4556
5303
  <div class="ngx-field__array-header">
4557
- <label class="ngx-field__label">{{ label }}</label>
5304
+ <label class="ngx-field__label">
5305
+ {{ label }}
5306
+ @if (hint) {
5307
+ <!-- Explains the set of rows, so it hangs off their name rather than any one row. -->
5308
+ <span class="ngx-field__hint-wrap">
5309
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5310
+ <span class="ngx-field__hint" role="tooltip" [attr.data-testid]="'field-' + field.id + '-hint'">{{
5311
+ hint
5312
+ }}</span>
5313
+ </span>
5314
+ }
5315
+ </label>
4558
5316
  @if (!readonly && !masked) {
4559
5317
  <button
4560
5318
  type="button"
@@ -4566,7 +5324,6 @@ class ArrayFieldComponent {
4566
5324
  </button>
4567
5325
  }
4568
5326
  </div>
4569
-
4570
5327
  @if (masked) {
4571
5328
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4572
5329
  maskedText
@@ -4625,7 +5382,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4625
5382
  [class.ngx-field--masked]="masked"
4626
5383
  >
4627
5384
  <div class="ngx-field__array-header">
4628
- <label class="ngx-field__label">{{ label }}</label>
5385
+ <label class="ngx-field__label">
5386
+ {{ label }}
5387
+ @if (hint) {
5388
+ <!-- Explains the set of rows, so it hangs off their name rather than any one row. -->
5389
+ <span class="ngx-field__hint-wrap">
5390
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5391
+ <span class="ngx-field__hint" role="tooltip" [attr.data-testid]="'field-' + field.id + '-hint'">{{
5392
+ hint
5393
+ }}</span>
5394
+ </span>
5395
+ }
5396
+ </label>
4629
5397
  @if (!readonly && !masked) {
4630
5398
  <button
4631
5399
  type="button"
@@ -4637,7 +5405,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4637
5405
  </button>
4638
5406
  }
4639
5407
  </div>
4640
-
4641
5408
  @if (masked) {
4642
5409
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4643
5410
  maskedText
@@ -4853,10 +5620,18 @@ class ImageFieldComponent {
4853
5620
  this.uploads.revokePreviewUrl(this.previewCache);
4854
5621
  }
4855
5622
  /** Validation, as distinct from the upload failures this field already reported. */
5623
+ /** Author help text, shown under the control and named by `aria-describedby`. */
5624
+ get hint() {
5625
+ return resolveLabel(this.field?.hint, this.language);
5626
+ }
5627
+ /** The ids of whatever is describing this control right now. */
5628
+ describedBy() {
5629
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
5630
+ }
4856
5631
  get errorMessage() {
4857
5632
  if (!this.control?.errors || !this.control.touched)
4858
5633
  return '';
4859
- return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
5634
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
4860
5635
  }
4861
5636
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImageFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4862
5637
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImageFieldComponent, isStandalone: true, selector: "ngx-image-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -4867,7 +5642,21 @@ class ImageFieldComponent {
4867
5642
  [class.ngx-field--readonly]="readonly"
4868
5643
  [class.ngx-field--masked]="masked"
4869
5644
  >
4870
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5645
+ <div class="ngx-field__label-row">
5646
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5647
+ @if (hint) {
5648
+ <span class="ngx-field__hint-wrap">
5649
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5650
+ <span
5651
+ class="ngx-field__hint"
5652
+ role="tooltip"
5653
+ [attr.data-testid]="'field-' + field.id + '-hint'"
5654
+ [id]="domId('-hint')"
5655
+ >{{ hint }}</span
5656
+ >
5657
+ </span>
5658
+ }
5659
+ </div>
4871
5660
  @if (masked) {
4872
5661
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4873
5662
  maskedText
@@ -4896,6 +5685,7 @@ class ImageFieldComponent {
4896
5685
  accept="image/*"
4897
5686
  [disabled]="field.disabled || uploading()"
4898
5687
  (change)="onFileSelect($any($event.target).files[0])"
5688
+ [attr.aria-describedby]="describedBy()"
4899
5689
  />
4900
5690
  }
4901
5691
  </label>
@@ -4911,9 +5701,13 @@ class ImageFieldComponent {
4911
5701
  ever showed the first. A required file left unchosen said nothing, and a
4912
5702
  configured required message had no element to appear in. -->
4913
5703
  @if (uploadError() || errorMessage) {
4914
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
4915
- uploadError() || errorMessage
4916
- }}</span>
5704
+ <span
5705
+ class="ngx-field__error"
5706
+ [attr.data-testid]="'field-' + field.id + '-error'"
5707
+ [id]="domId('-error')"
5708
+ role="alert"
5709
+ >{{ uploadError() || errorMessage }}</span
5710
+ >
4917
5711
  }
4918
5712
  }
4919
5713
  </div>
@@ -4934,7 +5728,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4934
5728
  [class.ngx-field--readonly]="readonly"
4935
5729
  [class.ngx-field--masked]="masked"
4936
5730
  >
4937
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5731
+ <div class="ngx-field__label-row">
5732
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5733
+ @if (hint) {
5734
+ <span class="ngx-field__hint-wrap">
5735
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5736
+ <span
5737
+ class="ngx-field__hint"
5738
+ role="tooltip"
5739
+ [attr.data-testid]="'field-' + field.id + '-hint'"
5740
+ [id]="domId('-hint')"
5741
+ >{{ hint }}</span
5742
+ >
5743
+ </span>
5744
+ }
5745
+ </div>
4938
5746
  @if (masked) {
4939
5747
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4940
5748
  maskedText
@@ -4963,6 +5771,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4963
5771
  accept="image/*"
4964
5772
  [disabled]="field.disabled || uploading()"
4965
5773
  (change)="onFileSelect($any($event.target).files[0])"
5774
+ [attr.aria-describedby]="describedBy()"
4966
5775
  />
4967
5776
  }
4968
5777
  </label>
@@ -4978,9 +5787,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4978
5787
  ever showed the first. A required file left unchosen said nothing, and a
4979
5788
  configured required message had no element to appear in. -->
4980
5789
  @if (uploadError() || errorMessage) {
4981
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
4982
- uploadError() || errorMessage
4983
- }}</span>
5790
+ <span
5791
+ class="ngx-field__error"
5792
+ [attr.data-testid]="'field-' + field.id + '-error'"
5793
+ [id]="domId('-error')"
5794
+ role="alert"
5795
+ >{{ uploadError() || errorMessage }}</span
5796
+ >
4984
5797
  }
4985
5798
  }
4986
5799
  </div>
@@ -5057,10 +5870,18 @@ class FileFieldComponent {
5057
5870
  this.control.markAsTouched();
5058
5871
  }
5059
5872
  /** Validation, as distinct from the upload failures this field already reported. */
5873
+ /** Author help text, shown under the control and named by `aria-describedby`. */
5874
+ get hint() {
5875
+ return resolveLabel(this.field?.hint, this.language);
5876
+ }
5877
+ /** The ids of whatever is describing this control right now. */
5878
+ describedBy() {
5879
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
5880
+ }
5060
5881
  get errorMessage() {
5061
5882
  if (!this.control?.errors || !this.control.touched)
5062
5883
  return '';
5063
- return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
5884
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
5064
5885
  }
5065
5886
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FileFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5066
5887
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: FileFieldComponent, isStandalone: true, selector: "ngx-file-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -5071,7 +5892,21 @@ class FileFieldComponent {
5071
5892
  [class.ngx-field--readonly]="readonly"
5072
5893
  [class.ngx-field--masked]="masked"
5073
5894
  >
5074
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5895
+ <div class="ngx-field__label-row">
5896
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5897
+ @if (hint) {
5898
+ <span class="ngx-field__hint-wrap">
5899
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5900
+ <span
5901
+ class="ngx-field__hint"
5902
+ role="tooltip"
5903
+ [attr.data-testid]="'field-' + field.id + '-hint'"
5904
+ [id]="domId('-hint')"
5905
+ >{{ hint }}</span
5906
+ >
5907
+ </span>
5908
+ }
5909
+ </div>
5075
5910
  @if (masked) {
5076
5911
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5077
5912
  maskedText
@@ -5110,6 +5945,7 @@ class FileFieldComponent {
5110
5945
  [id]="domId()"
5111
5946
  [disabled]="field.disabled || uploading()"
5112
5947
  (change)="onFileSelect($any($event.target).files[0])"
5948
+ [attr.aria-describedby]="describedBy()"
5113
5949
  />
5114
5950
  }
5115
5951
  </label>
@@ -5118,9 +5954,13 @@ class FileFieldComponent {
5118
5954
  ever showed the first. A required file left unchosen said nothing, and a
5119
5955
  configured required message had no element to appear in. -->
5120
5956
  @if (uploadError() || errorMessage) {
5121
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
5122
- uploadError() || errorMessage
5123
- }}</span>
5957
+ <span
5958
+ class="ngx-field__error"
5959
+ [attr.data-testid]="'field-' + field.id + '-error'"
5960
+ [id]="domId('-error')"
5961
+ role="alert"
5962
+ >{{ uploadError() || errorMessage }}</span
5963
+ >
5124
5964
  }
5125
5965
  }
5126
5966
  </div>
@@ -5141,7 +5981,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5141
5981
  [class.ngx-field--readonly]="readonly"
5142
5982
  [class.ngx-field--masked]="masked"
5143
5983
  >
5144
- <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5984
+ <div class="ngx-field__label-row">
5985
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
5986
+ @if (hint) {
5987
+ <span class="ngx-field__hint-wrap">
5988
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
5989
+ <span
5990
+ class="ngx-field__hint"
5991
+ role="tooltip"
5992
+ [attr.data-testid]="'field-' + field.id + '-hint'"
5993
+ [id]="domId('-hint')"
5994
+ >{{ hint }}</span
5995
+ >
5996
+ </span>
5997
+ }
5998
+ </div>
5145
5999
  @if (masked) {
5146
6000
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5147
6001
  maskedText
@@ -5180,6 +6034,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5180
6034
  [id]="domId()"
5181
6035
  [disabled]="field.disabled || uploading()"
5182
6036
  (change)="onFileSelect($any($event.target).files[0])"
6037
+ [attr.aria-describedby]="describedBy()"
5183
6038
  />
5184
6039
  }
5185
6040
  </label>
@@ -5188,9 +6043,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5188
6043
  ever showed the first. A required file left unchosen said nothing, and a
5189
6044
  configured required message had no element to appear in. -->
5190
6045
  @if (uploadError() || errorMessage) {
5191
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
5192
- uploadError() || errorMessage
5193
- }}</span>
6046
+ <span
6047
+ class="ngx-field__error"
6048
+ [attr.data-testid]="'field-' + field.id + '-error'"
6049
+ [id]="domId('-error')"
6050
+ role="alert"
6051
+ >{{ uploadError() || errorMessage }}</span
6052
+ >
5194
6053
  }
5195
6054
  }
5196
6055
  </div>
@@ -5372,6 +6231,37 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5372
6231
  args: [CONFIG_SOURCE]
5373
6232
  }] }] });
5374
6233
 
6234
+ /**
6235
+ * Grid width per field type, used only under `layout="auto"` and only where the field itself
6236
+ * names no `colSpan`.
6237
+ *
6238
+ * The rule behind the numbers is how much *content* the control holds, not how much room it
6239
+ * would happily fill: a date is eight characters and a currency amount is rarely more, so
6240
+ * giving either a full row buys nothing but scrolling. Anything that holds a paragraph, a
6241
+ * picture, or a form of its own keeps all twelve.
6242
+ *
6243
+ * A type missing from this table falls back to twelve, which is what an unrecognised custom
6244
+ * field type should get: the layout is conservative about controls it knows nothing about.
6245
+ */
6246
+ const AUTO_COL_SPAN = {
6247
+ text: 6,
6248
+ email: 6,
6249
+ password: 6,
6250
+ number: 4,
6251
+ currency: 4,
6252
+ date: 4,
6253
+ datetime: 4,
6254
+ time: 4,
6255
+ monthYear: 4,
6256
+ dropdown: 6,
6257
+ radio: 6,
6258
+ multiSelect: 6,
6259
+ boolean: 4,
6260
+ checkbox: 4,
6261
+ 'entity-ref': 6,
6262
+ // Left at the full width deliberately — textarea, markdown, image, file, group and array
6263
+ // each hold something a half-row would crop.
6264
+ };
5375
6265
  /**
5376
6266
  * DynamicFormComponent — the main form component.
5377
6267
  * Renders a reactive form from EntityFormConfig with tab support, responsive 12-col grid,
@@ -5429,6 +6319,19 @@ class DynamicFormComponent {
5429
6319
  * disable the form. Used by the builder's live preview.
5430
6320
  */
5431
6321
  preview = false;
6322
+ /**
6323
+ * How a field with no `colSpan` of its own is sized in the 12-column grid.
6324
+ *
6325
+ * `stack` — the historic behaviour — gives every such field the full twelve, so a form of
6326
+ * eight short text fields renders as an eight-row ladder with two thirds of its width
6327
+ * empty. `auto` sizes them by field type instead: a date or a number is half a row, a
6328
+ * textarea or a nested group still takes all twelve.
6329
+ *
6330
+ * An explicit `colSpan` always wins in both modes, so turning `auto` on cannot override a
6331
+ * layout somebody authored. The default stays `stack` because changing how existing configs
6332
+ * render is not this input's business — opting in is.
6333
+ */
6334
+ layout = 'stack';
5432
6335
  // ─── Outputs ──────────────────────────────────────────────────────────────
5433
6336
  formSubmit = new EventEmitter();
5434
6337
  formChange = new EventEmitter();
@@ -5447,9 +6350,15 @@ class DynamicFormComponent {
5447
6350
  hookRegistry = inject(HookRegistryService);
5448
6351
  rbacService = inject(RbacService);
5449
6352
  rulesEvaluation = inject(RulesEvaluationService);
6353
+ /** Resolves what is wrong with a field, for the error summary — see `resolveForField`. */
6354
+ messages = inject(ValidationMessagesService);
5450
6355
  entityRefSelection = inject(EntityRefSelectionService);
5451
6356
  commonModulesRegistry = inject(COMMON_MODULES_REGISTRY, { optional: true });
5452
6357
  migrations = inject(RECORD_MIGRATIONS, { optional: true }) ?? [];
6358
+ /** Scopes the field-slot lookup a jump does, so the library never touches global `document`. */
6359
+ host = inject(ElementRef);
6360
+ /** Ties `afterNextRender` to this component, so destroying it cancels a pending jump. */
6361
+ injector = inject(Injector);
5453
6362
  Object = Object;
5454
6363
  // ─── Signals (local reactive state) ───────────────────────────────────────
5455
6364
  activeTab = signal('', ...(ngDevMode ? [{ debugName: "activeTab" }] : /* istanbul ignore next */ []));
@@ -5475,9 +6384,12 @@ class DynamicFormComponent {
5475
6384
  handleKeyboardEvent(event) {
5476
6385
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
5477
6386
  event.preventDefault();
5478
- if (!this.readonly && this.canSubmit && this.form?.valid) {
6387
+ // No `form.valid` check: the shortcut is the Save button, and pressing Save on an
6388
+ // invalid form is what produces the error summary. Guarding here instead meant Ctrl+S
6389
+ // on an incomplete form did nothing at all — and unlike the button, a shortcut cannot
6390
+ // even look disabled.
6391
+ if (!this.readonly && this.canSubmit)
5479
6392
  this.submit();
5480
- }
5481
6393
  }
5482
6394
  }
5483
6395
  // ─── Computed ─────────────────────────────────────────────────────────────
@@ -5628,10 +6540,35 @@ class DynamicFormComponent {
5628
6540
  get isValidating() {
5629
6541
  return this.form.pending;
5630
6542
  }
5631
- /** Single source of truth for both the submit guard and the button's disabled state. */
6543
+ /**
6544
+ * Whether a submit would be refused. The guard `submit()` checks — not the button's state.
6545
+ *
6546
+ * See `submitDisabled` for why those are no longer the same question.
6547
+ */
5632
6548
  get submitBlocked() {
5633
6549
  return this.form.invalid || this.form.pending || this.hasRuleErrors;
5634
6550
  }
6551
+ /**
6552
+ * Whether the Save button is unavailable.
6553
+ *
6554
+ * This used to be `submitBlocked`, so an invalid form greyed Save out — and a greyed button
6555
+ * is the worst possible answer to "why can't I save?". It cannot be clicked, so there is no
6556
+ * moment at which the form gets to say which field is at fault or which tab it is on; the
6557
+ * user is left comparing a disabled button against a form that looks, to them, filled in.
6558
+ * On a tabbed form the offending field is usually not even on screen.
6559
+ *
6560
+ * Save now stays available while the form is merely invalid. Clicking it still saves
6561
+ * nothing — `submit()` checks `submitBlocked` and refuses — but the refusal is where the
6562
+ * error summary, the tab badges and the jump to the first bad field come from, so pressing
6563
+ * Save produces an explanation instead of silence.
6564
+ *
6565
+ * What is still disabled is what a *retry cannot fix*: a save already in flight, and an
6566
+ * async validator whose answer has not come back. Those are moments where the right answer
6567
+ * is genuinely "wait", and a button that accepts a click would be lying.
6568
+ */
6569
+ get submitDisabled() {
6570
+ return this.isSaving() || this.loading || this.form.pending;
6571
+ }
5635
6572
  ngOnInit() {
5636
6573
  this.selectionSub = this.entityRefSelection.selection$.subscribe(({ fieldId, option }) => {
5637
6574
  this.runAutoPatch(fieldId, option?.record);
@@ -5732,9 +6669,113 @@ class DynamicFormComponent {
5732
6669
  this.focusActivePanel();
5733
6670
  }
5734
6671
  getFieldSpan(field) {
5735
- const span = field.colSpan ?? 12;
6672
+ const span = field.colSpan ?? (this.layout === 'auto' ? (AUTO_COL_SPAN[field.type] ?? 12) : 12);
5736
6673
  return `span ${Math.min(12, Math.max(1, span))}`;
5737
6674
  }
6675
+ // ─── Validation recovery ──────────────────────────────────────────────────
6676
+ /**
6677
+ * Set by a submit that validation refused, cleared by one that went through.
6678
+ *
6679
+ * Gates the error summary rather than `form.invalid` doing it: a form opened on an empty
6680
+ * required field is invalid from the first render, and leading with a block of red before
6681
+ * the user has typed anything is an accusation, not help. The summary is the answer to
6682
+ * "why did Save do nothing", so it appears when Save does nothing.
6683
+ */
6684
+ submitAttempted = signal(false, ...(ngDevMode ? [{ debugName: "submitAttempted" }] : /* istanbul ignore next */ []));
6685
+ /**
6686
+ * Every rendered field whose control is currently invalid, with the tab it lives on.
6687
+ *
6688
+ * Read by the tab badges, the error summary and the jump, so all three agree on what
6689
+ * counts. Hidden fields are excluded because `syncHiddenFieldState` disables them and a
6690
+ * disabled control is not part of validity — pointing the user at one would send them to a
6691
+ * field that is not on screen, or worse, switch tabs to show them nothing.
6692
+ *
6693
+ * Cached per change-detection pass. The walk calls `getControl` once per field and that
6694
+ * falls back to a recursive search of the whole form; the template reads this three times
6695
+ * for the summary and once per tab for the badges, so recomputing it each time turns one
6696
+ * traversal into a dozen on every keystroke. `form.statusChanges` is the only thing that
6697
+ * can alter the answer, and it already runs `markForCheck` — dropping the cache there keeps
6698
+ * it honest without a second subscription.
6699
+ */
6700
+ invalidFields() {
6701
+ if (this.invalidFieldsCache)
6702
+ return this.invalidFieldsCache;
6703
+ const out = [];
6704
+ const collect = (tab, parentId) => {
6705
+ for (const field of tab.fields ?? []) {
6706
+ const control = this.getControl(field.id, tab.id);
6707
+ if (!control || control.disabled || control.valid || control.pending)
6708
+ continue;
6709
+ // The same message the field renders under itself — see `resolveForField`. Saying
6710
+ // only *which* field is wrong leaves the user to go and look at each one; saying what
6711
+ // is wrong with it is usually enough to fix it without leaving the summary.
6712
+ const message = this.messages.resolveForField(control.errors, this.language, field.type);
6713
+ out.push(parentId
6714
+ ? { field, message, tabId: parentId, subTabId: tab.id }
6715
+ : { field, message, tabId: tab.id });
6716
+ }
6717
+ for (const child of tab.children ?? [])
6718
+ collect(child, parentId ?? tab.id);
6719
+ };
6720
+ for (const tab of this.visibleTabs)
6721
+ collect(tab);
6722
+ return (this.invalidFieldsCache = out);
6723
+ }
6724
+ invalidFieldsCache;
6725
+ /** How many invalid fields sit on a tab — its own and its sub-tabs'. */
6726
+ tabErrorCount(tab) {
6727
+ if (!this.submitAttempted())
6728
+ return 0;
6729
+ return this.invalidFields().filter(entry => entry.tabId === tab.id).length;
6730
+ }
6731
+ /** The same count for a sub-tab, which is addressed by its own id rather than its parent's. */
6732
+ subTabErrorCount(subTab) {
6733
+ if (!this.submitAttempted())
6734
+ return 0;
6735
+ return this.invalidFields().filter(entry => entry.subTabId === subTab.id).length;
6736
+ }
6737
+ /**
6738
+ * Switch to the tab holding `fieldId`, scroll it into view, and move focus onto it.
6739
+ *
6740
+ * `afterNextRender` rather than a timer: the panel the field lives in is rendered by the
6741
+ * change detection this call is part of, so the element does not exist yet — and a timeout
6742
+ * is both a guess at how long that takes and something that keeps running after the
6743
+ * component is destroyed. The query is scoped to this component's own element, so the
6744
+ * library never reaches for the global `document`.
6745
+ */
6746
+ jumpToField(fieldId) {
6747
+ const location = this.invalidFields().find(entry => entry.field.id === fieldId) ?? this.locateField(fieldId);
6748
+ if (!location)
6749
+ return;
6750
+ // The panel must not take focus back — this jump is going to focus the field itself.
6751
+ this.setActiveTab(location.tabId, { focusPanel: false });
6752
+ // `setActiveTab` resets to a tab's first child, so the sub-tab is selected after it.
6753
+ if (location.subTabId)
6754
+ this.setActiveSubTab(location.subTabId, { focusPanel: false });
6755
+ afterNextRender(() => {
6756
+ // The id comes from config, so it never goes into a selector string: no escaping to
6757
+ // get wrong, and no need for `CSS.escape`, which jsdom does not provide.
6758
+ const wanted = `field-container-${fieldId}`;
6759
+ const el = Array.from(this.host.nativeElement.querySelectorAll('[id^="field-container-"]')).find(slot => slot.id === wanted);
6760
+ if (!el)
6761
+ return;
6762
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
6763
+ // The slot carries tabindex="-1", so this actually moves focus rather than only
6764
+ // scrolling and leaving focus behind on the link that was clicked.
6765
+ el.focus();
6766
+ }, { injector: this.injector });
6767
+ }
6768
+ locateField(fieldId) {
6769
+ for (const tab of this.tabs) {
6770
+ if ((tab.fields ?? []).some(f => f.id === fieldId))
6771
+ return { tabId: tab.id };
6772
+ for (const child of tab.children ?? []) {
6773
+ if ((child.fields ?? []).some(f => f.id === fieldId))
6774
+ return { tabId: tab.id, subTabId: child.id };
6775
+ }
6776
+ }
6777
+ return null;
6778
+ }
5738
6779
  // ─── criticalField locking ────────────────────────────────────────────────
5739
6780
  /** A critical field stays read-only until the user deliberately unlocks it. */
5740
6781
  isFieldLocked(field) {
@@ -5767,8 +6808,15 @@ class DynamicFormComponent {
5767
6808
  next.add(field.id);
5768
6809
  this.unlockedFields.set(next);
5769
6810
  }
6811
+ /**
6812
+ * A field's label in the form's language, falling back to its id.
6813
+ *
6814
+ * The fallback matters wherever the label stands alone rather than beside its control: an
6815
+ * error-summary chip or a critical-change banner naming a field with no label for this
6816
+ * language would otherwise be blank, and a blank chip is worse than a raw id.
6817
+ */
5770
6818
  resolveFieldLabel(field) {
5771
- return resolveLabel(field.label, this.language);
6819
+ return resolveLabel(field.label, this.language) || field.id;
5772
6820
  }
5773
6821
  /**
5774
6822
  * The changed critical fields as one comma-separated string.
@@ -5838,7 +6886,13 @@ class DynamicFormComponent {
5838
6886
  // never re-enable. submitBlocked reads form.pending and form.invalid, so this is what
5839
6887
  // keeps it honest.
5840
6888
  this.statusSub?.unsubscribe();
5841
- this.statusSub = this.form.statusChanges.subscribe(() => this.cdr.markForCheck());
6889
+ // A rebuild replaces every control, so anything cached against the old ones is stale.
6890
+ this.invalidFieldsCache = undefined;
6891
+ this.statusSub = this.form.statusChanges.subscribe(() => {
6892
+ // The only thing that can change which fields are invalid — see `invalidFields`.
6893
+ this.invalidFieldsCache = undefined;
6894
+ this.cdr.markForCheck();
6895
+ });
5842
6896
  this.valueSub = changes$.subscribe(() => {
5843
6897
  const flattened = this.flattenFormValues();
5844
6898
  this.formValues.set(flattened);
@@ -6347,9 +7401,23 @@ class DynamicFormComponent {
6347
7401
  }
6348
7402
  async submit() {
6349
7403
  if (!this.canSubmit || this.submitBlocked) {
7404
+ /*
7405
+ * A refused save used to mark every control touched and stop there.
7406
+ *
7407
+ * On a tabbed form that is indistinguishable from a broken button: the errors appear on
7408
+ * whichever tabs hold them, the user is looking at a different one, and nothing on
7409
+ * screen changes. Three things fix it, and all three are needed — the summary says how
7410
+ * many and which, the tab badges say where, and this jump takes the user to the first.
7411
+ */
6350
7412
  this.markAllTouched();
7413
+ this.submitAttempted.set(true);
7414
+ const first = this.invalidFields()[0];
7415
+ if (first)
7416
+ this.jumpToField(first.field.id);
6351
7417
  return;
6352
7418
  }
7419
+ // Whatever the last attempt complained about is settled, so the summary goes away.
7420
+ this.submitAttempted.set(false);
6353
7421
  const rawData = this.extractRecord();
6354
7422
  let processedData = rawData;
6355
7423
  this.isSaving.set(true);
@@ -6387,14 +7455,16 @@ class DynamicFormComponent {
6387
7455
  this.formValues.set(values);
6388
7456
  this.previousValues = { ...values };
6389
7457
  this.unlockedFields.set(new Set());
7458
+ // Nothing has been attempted against the restored values, so the old complaint is stale.
7459
+ this.submitAttempted.set(false);
6390
7460
  this.formReset.emit();
6391
7461
  }
6392
7462
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6393
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media(max-width:768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7463
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview", layout: "layout" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6394
7464
  }
6395
7465
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
6396
7466
  type: Component,
6397
- args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media(max-width:768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"] }]
7467
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
6398
7468
  }], propDecorators: { config: [{
6399
7469
  type: Input
6400
7470
  }], rules: [{
@@ -6421,6 +7491,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6421
7491
  type: Input
6422
7492
  }], preview: [{
6423
7493
  type: Input
7494
+ }], layout: [{
7495
+ type: Input
6424
7496
  }], formSubmit: [{
6425
7497
  type: Output
6426
7498
  }], formChange: [{
@@ -6467,6 +7539,8 @@ class DynamicRecordFormComponent {
6467
7539
  isReadOnly = false;
6468
7540
  /** Specific field ids forced read-only while the rest of the record stays editable. */
6469
7541
  readOnlyFields = [];
7542
+ /** Forwarded verbatim to the hosted form — see `DynamicFormComponent.layout`. */
7543
+ layout = 'stack';
6470
7544
  /**
6471
7545
  * Open the record read-only, with a per-tab "Edit section" flow — one tab is edited and
6472
7546
  * validated at a time. This is the framework's `EntityRecordComponent` model and the
@@ -6480,11 +7554,19 @@ class DynamicRecordFormComponent {
6480
7554
  formReset = new EventEmitter();
6481
7555
  /** One tab section was saved. Carries the whole record, plus which tab was edited. */
6482
7556
  sectionSave = new EventEmitter();
7557
+ /**
7558
+ * A `beforeSave` hook aborted the save — by returning false or throwing.
7559
+ *
7560
+ * Re-exposed from the embedded `ngx-dynamic-form`, and emitted by `saveSection` too. The
7561
+ * inner form already ran the hook and already refused the save, but nothing carried that
7562
+ * out to a host binding this component: the veto worked and looked exactly like a Save
7563
+ * button that did nothing, which is the failure `saveRejected` exists to prevent.
7564
+ */
7565
+ saveRejected = new EventEmitter();
6483
7566
  dynamicFormComp;
6484
7567
  rulesEvaluation = inject(RulesEvaluationService);
6485
7568
  rbacService = inject(RbacService);
6486
- host = inject(ElementRef);
6487
- injector = inject(Injector);
7569
+ hookRegistry = inject(HookRegistryService);
6488
7570
  currentData = signal({}, ...(ngDevMode ? [{ debugName: "currentData" }] : /* istanbul ignore next */ []));
6489
7571
  originalBaseline = signal({}, ...(ngDevMode ? [{ debugName: "originalBaseline" }] : /* istanbul ignore next */ []));
6490
7572
  /**
@@ -6704,7 +7786,7 @@ class DynamicRecordFormComponent {
6704
7786
  * validity of the tab's own controls, and rules filtered to this tab — a rule targeting
6705
7787
  * a hidden tab must not block the save (the reference's OV0-968 fix).
6706
7788
  */
6707
- saveSection() {
7789
+ async saveSection() {
6708
7790
  const tabId = this.editingTabId();
6709
7791
  const form = this.dynamicFormComp;
6710
7792
  if (!tabId || !form)
@@ -6726,10 +7808,50 @@ class DynamicRecordFormComponent {
6726
7808
  return;
6727
7809
  }
6728
7810
  this.sectionErrors.set({});
6729
- this.editingTabId.set(null);
6730
7811
  const record = form.extractRecord();
6731
- this.currentData.set(record);
6732
- this.sectionSave.emit({ tabId, record });
7812
+ // Only await when there is something to await. An async function runs synchronously up
7813
+ // to its first `await`, so this keeps `sectionSave` firing in the same tick for the
7814
+ // hosts that register no hook — which is how it has always behaved, and which a host
7815
+ // may well depend on. Awaiting an already-resolved promise would defer the emit by a
7816
+ // microtask for everybody, to no purpose.
7817
+ const hookKey = `${this.config?.entity}:beforeSave`;
7818
+ const reviewed = this.hookRegistry.has(hookKey) ? await this.runBeforeSave(record) : record;
7819
+ // Vetoed: the section stays open, exactly as the whole-record form stays open, so the
7820
+ // user is looking at the values the hook refused rather than at a closed section.
7821
+ if (reviewed === null)
7822
+ return;
7823
+ this.editingTabId.set(null);
7824
+ this.currentData.set(reviewed);
7825
+ this.sectionSave.emit({ tabId, record: reviewed });
7826
+ }
7827
+ /**
7828
+ * Put a record to the entity's `beforeSave` hook.
7829
+ *
7830
+ * `sectionSave` carries the *whole* record — `extractRecord()`, not the tab's slice — so
7831
+ * the same payload reached persistence through two buttons, one of which asked the hook
7832
+ * and one of which did not. A hook registered to veto a save is a data-integrity
7833
+ * mechanism; having a second route around it made it advisory.
7834
+ *
7835
+ * @returns the record to emit, or `null` when the hook aborted (already reported).
7836
+ */
7837
+ async runBeforeSave(record) {
7838
+ const hookKey = `${this.config?.entity}:beforeSave`;
7839
+ // Defensive: `saveSection` checks this before awaiting, but a later caller may not.
7840
+ if (!this.hookRegistry.has(hookKey))
7841
+ return record;
7842
+ try {
7843
+ const result = await this.hookRegistry.run(hookKey, record);
7844
+ if (result === false) {
7845
+ this.saveRejected.emit({ reason: 'beforeSave returned false' });
7846
+ return null;
7847
+ }
7848
+ // Anything else is the payload to save; `undefined`/`true` mean "unchanged".
7849
+ return result === undefined || result === true ? record : result;
7850
+ }
7851
+ catch (err) {
7852
+ this.saveRejected.emit({ reason: err instanceof Error ? err.message : String(err), error: err });
7853
+ return null;
7854
+ }
6733
7855
  }
6734
7856
  /**
6735
7857
  * Seed the session baseline from the loaded record so `VALUE_CHANGED` rules and the
@@ -6755,8 +7877,56 @@ class DynamicRecordFormComponent {
6755
7877
  return 'Record';
6756
7878
  return this.config.name ? resolveLabel(this.config.name, this.language) : this.config.entity;
6757
7879
  }
7880
+ /**
7881
+ * What this record is called, as opposed to what kind of thing it is.
7882
+ *
7883
+ * The header used to show the entity's name twice — "clients", with "Entity: clients"
7884
+ * underneath — so the one question a record header exists to answer, *which* record am I
7885
+ * looking at, was the one thing it did not say. The entity keeps the subtitle; this takes
7886
+ * the heading.
7887
+ *
7888
+ * The order is deliberate. `showOnMinimize` is the author saying "this is what identifies a
7889
+ * record" — it is what the summary panel and the collapsed view already show — so it is
7890
+ * asked first. A text or email field is the fallback because those are what a name is
7891
+ * stored in; a number or a date is a property of the record, not its identity, and would
7892
+ * give a header reading "42". When neither exists there is nothing to name it with and the
7893
+ * entity label stands in, which is the behaviour this replaced.
7894
+ *
7895
+ * A masked field is never a candidate. Masking is presentational (see SECURITY.md), but
7896
+ * that is exactly why: the whole point is that this role does not get to read the value, and
7897
+ * promoting it into the page heading would print in full what every field renders as
7898
+ * `XXXXXXXXX`. A record whose only text field is masked keeps the entity label.
7899
+ *
7900
+ * One pass rather than two filtered ones: this is a getter the header reads on every change
7901
+ * detection, and the preferred candidate is usually the first field it meets.
7902
+ */
7903
+ get recordHeading() {
7904
+ const named = (field) => {
7905
+ const value = this.fieldValue(field.id);
7906
+ if (typeof value !== 'string' && typeof value !== 'number')
7907
+ return null;
7908
+ const text = String(value).trim();
7909
+ return text.length ? text : null;
7910
+ };
7911
+ let fallback = null;
7912
+ for (const field of this.allFields()) {
7913
+ if (field.visibility === false)
7914
+ continue;
7915
+ if (this.rbacService.shouldMaskField(field, undefined, this.config, this.userRoles))
7916
+ continue;
7917
+ if (field.showOnMinimize) {
7918
+ const preferred = named(field);
7919
+ if (preferred)
7920
+ return preferred;
7921
+ }
7922
+ else if (!fallback && (field.type === 'text' || field.type === 'email')) {
7923
+ fallback = named(field);
7924
+ }
7925
+ }
7926
+ return fallback ?? this.recordTitle;
7927
+ }
6758
7928
  get avatarLetter() {
6759
- return (this.recordTitle || 'R').charAt(0).toUpperCase();
7929
+ return (this.recordHeading || 'R').charAt(0).toUpperCase();
6760
7930
  }
6761
7931
  // ─── Header fields (isProfileImage / isHeaderToggle) ────────────────────────
6762
7932
  /** Every field in the config, at any depth. */
@@ -6900,61 +8070,24 @@ class DynamicRecordFormComponent {
6900
8070
  * field. The walk used to check top-level `fields` only, so every field in a sub-tab was
6901
8071
  * simply not found and the jump did nothing at all.
6902
8072
  */
6903
- locateField(fieldId) {
6904
- for (const tab of this.config?.tabs || []) {
6905
- if ((tab.fields || []).some(f => f.id === fieldId))
6906
- return { tabId: tab.id };
6907
- for (const child of tab.children || []) {
6908
- if ((child.fields || []).some(f => f.id === fieldId)) {
6909
- return { tabId: tab.id, subTabId: child.id };
6910
- }
6911
- }
6912
- }
6913
- return null;
6914
- }
6915
8073
  /**
6916
8074
  * Switches to the tab holding `fieldId`, then scrolls it into view and moves focus to it.
6917
8075
  *
6918
- * The wait for the new tab to render used to be a 50 ms `setTimeout`, which was wrong three
6919
- * ways: `document` is undefined on a server render, 50 ms is a guess that a large tab can
6920
- * outrun, and nothing cancelled the timer if the component went away first.
6921
- * `afterNextRender` fixes all three — it never runs on the server, it runs when the panel
6922
- * has actually rendered rather than when a guess expires, and it is tied to the injector so
6923
- * destroying the component cancels it. The query is scoped to this component's own element,
6924
- * so the library no longer reaches for the global `document` at all.
8076
+ * Delegated rather than implemented twice. The hosted form grew the same jump when a
8077
+ * refused save had to be able to take the user to the first invalid field, and the form is
8078
+ * where it belongs: it owns the tab state, the panel that renders the field, and the
8079
+ * element the jump is looking for. This is kept as the record editor's public entry point
8080
+ * because the summary panel's quick-jump calls it and a host may too.
6925
8081
  */
6926
8082
  jumpToField(fieldId) {
6927
- const location = this.locateField(fieldId);
6928
- if (!location)
6929
- return;
6930
- // The panel must not take the focus back: this jump is going to focus the field itself.
6931
- this.dynamicFormComp?.setActiveTab(location.tabId, { focusPanel: false });
6932
- // `setActiveTab` resets to a tab's first child, so the sub-tab has to be selected after
6933
- // it, not before. Without this the walk found sub-tab fields and then rendered the wrong
6934
- // panel, so the element the callback below looks for never existed.
6935
- if (location.subTabId) {
6936
- this.dynamicFormComp?.setActiveSubTab(location.subTabId, { focusPanel: false });
6937
- }
6938
- afterNextRender(() => {
6939
- // The field id comes from config, so it never goes into a selector string: no
6940
- // escaping to get wrong, and no need for `CSS.escape`, which jsdom and older
6941
- // browsers do not provide and whose absence throws silently inside a render hook.
6942
- const wanted = `field-container-${fieldId}`;
6943
- const el = Array.from(this.host.nativeElement.querySelectorAll('[id^="field-container-"]')).find(slot => slot.id === wanted);
6944
- if (!el)
6945
- return;
6946
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
6947
- // The slot carries tabindex="-1" so this actually moves focus. Without it the jump
6948
- // scrolled the field into view and left focus behind on the link.
6949
- el.focus();
6950
- }, { injector: this.injector });
8083
+ this.dynamicFormComp?.jumpToField(fieldId);
6951
8084
  }
6952
8085
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6953
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: config.entity }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
8086
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", layout: "layout", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave", saveRejected: "saveRejected" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordHeading\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <!--\n The record's own name, with the entity underneath. These were the other way round \u2014\n both showing the entity \u2014 so the header never said which record was open.\n -->\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordHeading }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: recordTitle }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [layout]=\"layout\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (saveRejected)=\"saveRejected.emit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);background:var(--ngx-color-surface, #ffffff);border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius, 12px);padding:20px}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;gap:14px;padding-bottom:var(--ngx-gap-sm, 16px);border-bottom:1px solid var(--ngx-color-border, #f3f4f6)}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px;min-width:0}.ngx-record-editor__avatar{width:44px;height:44px;border-radius:50%;object-fit:cover;background:var(--ngx-color-accent-soft, #e0e7ff);color:var(--ngx-color-accent, #4f46e5);border:1px solid var(--ngx-color-border, transparent);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:17px;flex:none}.ngx-record-editor__title{font-size:19px;font-weight:700;color:var(--ngx-color-text, #111827);margin:0;letter-spacing:-.01em}.ngx-record-editor__subtitle{font-size:var(--ngx-hint-size, 13px);color:var(--ngx-color-muted, #6b7280)}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:var(--ngx-label-size, 13px);font-weight:var(--ngx-label-weight, 600);color:var(--ngx-color-text, #374151)}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:11px 14px;border-radius:var(--ngx-radius-sm, 8px);font-size:var(--ngx-font-size, 13px);border:1px solid transparent;border-left-width:3px}.ngx-record-editor__banner--info{background:var(--ngx-color-info-soft, #eff6ff);border-color:var(--ngx-color-border, #dbeafe);border-left-color:var(--ngx-color-info, #1d4ed8);color:var(--ngx-color-info, #1d4ed8);display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner--error{background:var(--ngx-color-error-soft, #fef2f2);border-color:var(--ngx-color-border, #fecaca);border-left-color:var(--ngx-color-error, #b91c1c);color:var(--ngx-color-error, #b91c1c)}.ngx-record-editor__banner--warning{background:var(--ngx-color-warning-soft, #fffbeb);border-color:var(--ngx-color-border, #fef3c7);border-left-color:var(--ngx-color-warning, #b45309);color:var(--ngx-color-warning, #b45309)}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:var(--ngx-hint-size, 12px);color:var(--ngx-color-muted, #6b7280)}.ngx-record-editor__rows{border:1px solid var(--ngx-color-border, #f3f4f6);border-radius:var(--ngx-radius-sm, 8px);padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-record-editor__row-text{flex:1;font-size:var(--ngx-font-size, 13px);overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:var(--ngx-color-surface, none);border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font:inherit;font-size:var(--ngx-hint-size, 12px);font-weight:var(--ngx-label-weight, 600);color:inherit;padding:4px 10px}.ngx-record-editor__row-btn:hover{border-color:var(--ngx-color-accent, #e5e7eb);color:var(--ngx-color-accent, inherit)}.ngx-record-editor__row-empty{font-size:var(--ngx-hint-size, 12px);color:var(--ngx-color-muted, #6b7280);margin:0}.ngx-record-editor__drawer{border:1px solid var(--ngx-color-border, #dbeafe);background:var(--ngx-color-surface-alt, #f8fafc);border-radius:var(--ngx-radius, 8px);padding:16px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:var(--ngx-color-surface-alt, #f9fafb);border:1px solid var(--ngx-color-border, #f3f4f6);border-radius:var(--ngx-radius, 8px);padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:4px var(--ngx-gap, 12px)}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:var(--ngx-radius-sm, 6px);min-width:0;transition:background .15s}.ngx-record-editor__summary-item:hover{background:var(--ngx-color-accent-soft, #eff6ff)}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--ngx-color-muted, #6b7280);font-weight:600}.ngx-record-editor__summary-val{font-size:var(--ngx-font-size, 14px);font-weight:600;color:var(--ngx-color-text, #1f2937);overflow-wrap:anywhere}@media(prefers-reduced-motion:reduce){.ngx-record-editor__summary-item{transition:none}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview", "layout"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6954
8087
  }
6955
8088
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, decorators: [{
6956
8089
  type: Component,
6957
- args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-record-form', standalone: true, imports: [CommonModule, ReactiveFormsModule, DynamicFormComponent, DynamicFieldComponent], template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: config.entity }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"] }]
8090
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-record-form', standalone: true, imports: [CommonModule, ReactiveFormsModule, DynamicFormComponent, DynamicFieldComponent], template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordHeading\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <!--\n The record's own name, with the entity underneath. These were the other way round \u2014\n both showing the entity \u2014 so the header never said which record was open.\n -->\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordHeading }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: recordTitle }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [layout]=\"layout\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (saveRejected)=\"saveRejected.emit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);background:var(--ngx-color-surface, #ffffff);border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius, 12px);padding:20px}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;gap:14px;padding-bottom:var(--ngx-gap-sm, 16px);border-bottom:1px solid var(--ngx-color-border, #f3f4f6)}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px;min-width:0}.ngx-record-editor__avatar{width:44px;height:44px;border-radius:50%;object-fit:cover;background:var(--ngx-color-accent-soft, #e0e7ff);color:var(--ngx-color-accent, #4f46e5);border:1px solid var(--ngx-color-border, transparent);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:17px;flex:none}.ngx-record-editor__title{font-size:19px;font-weight:700;color:var(--ngx-color-text, #111827);margin:0;letter-spacing:-.01em}.ngx-record-editor__subtitle{font-size:var(--ngx-hint-size, 13px);color:var(--ngx-color-muted, #6b7280)}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:var(--ngx-label-size, 13px);font-weight:var(--ngx-label-weight, 600);color:var(--ngx-color-text, #374151)}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:11px 14px;border-radius:var(--ngx-radius-sm, 8px);font-size:var(--ngx-font-size, 13px);border:1px solid transparent;border-left-width:3px}.ngx-record-editor__banner--info{background:var(--ngx-color-info-soft, #eff6ff);border-color:var(--ngx-color-border, #dbeafe);border-left-color:var(--ngx-color-info, #1d4ed8);color:var(--ngx-color-info, #1d4ed8);display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner--error{background:var(--ngx-color-error-soft, #fef2f2);border-color:var(--ngx-color-border, #fecaca);border-left-color:var(--ngx-color-error, #b91c1c);color:var(--ngx-color-error, #b91c1c)}.ngx-record-editor__banner--warning{background:var(--ngx-color-warning-soft, #fffbeb);border-color:var(--ngx-color-border, #fef3c7);border-left-color:var(--ngx-color-warning, #b45309);color:var(--ngx-color-warning, #b45309)}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:var(--ngx-hint-size, 12px);color:var(--ngx-color-muted, #6b7280)}.ngx-record-editor__rows{border:1px solid var(--ngx-color-border, #f3f4f6);border-radius:var(--ngx-radius-sm, 8px);padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-record-editor__row-text{flex:1;font-size:var(--ngx-font-size, 13px);overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:var(--ngx-color-surface, none);border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font:inherit;font-size:var(--ngx-hint-size, 12px);font-weight:var(--ngx-label-weight, 600);color:inherit;padding:4px 10px}.ngx-record-editor__row-btn:hover{border-color:var(--ngx-color-accent, #e5e7eb);color:var(--ngx-color-accent, inherit)}.ngx-record-editor__row-empty{font-size:var(--ngx-hint-size, 12px);color:var(--ngx-color-muted, #6b7280);margin:0}.ngx-record-editor__drawer{border:1px solid var(--ngx-color-border, #dbeafe);background:var(--ngx-color-surface-alt, #f8fafc);border-radius:var(--ngx-radius, 8px);padding:16px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:var(--ngx-color-surface-alt, #f9fafb);border:1px solid var(--ngx-color-border, #f3f4f6);border-radius:var(--ngx-radius, 8px);padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:4px var(--ngx-gap, 12px)}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:var(--ngx-radius-sm, 6px);min-width:0;transition:background .15s}.ngx-record-editor__summary-item:hover{background:var(--ngx-color-accent-soft, #eff6ff)}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--ngx-color-muted, #6b7280);font-weight:600}.ngx-record-editor__summary-val{font-size:var(--ngx-font-size, 14px);font-weight:600;color:var(--ngx-color-text, #1f2937);overflow-wrap:anywhere}@media(prefers-reduced-motion:reduce){.ngx-record-editor__summary-item{transition:none}}\n"] }]
6958
8091
  }], propDecorators: { config: [{
6959
8092
  type: Input
6960
8093
  }], rules: [{
@@ -6975,6 +8108,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6975
8108
  type: Input
6976
8109
  }], readOnlyFields: [{
6977
8110
  type: Input
8111
+ }], layout: [{
8112
+ type: Input
6978
8113
  }], viewMode: [{
6979
8114
  type: Input
6980
8115
  }], formSubmit: [{
@@ -6985,6 +8120,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6985
8120
  type: Output
6986
8121
  }], sectionSave: [{
6987
8122
  type: Output
8123
+ }], saveRejected: [{
8124
+ type: Output
6988
8125
  }], dynamicFormComp: [{
6989
8126
  type: ViewChild,
6990
8127
  args: [DynamicFormComponent]
@@ -6996,5 +8133,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6996
8133
  * Generated bundle index. Do not edit.
6997
8134
  */
6998
8135
 
6999
- export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
8136
+ export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, fieldDescribedBy, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
7000
8137
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map