ngx-dynamic-entity 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>
@@ -2008,18 +2407,18 @@ class DateFieldComponent {
2008
2407
  * "This field has an error", which made a documented, configurable feature work on three
2009
2408
  * of fifteen field types.
2010
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
+ }
2011
2418
  get errorMessage() {
2012
2419
  if (!this.control?.errors || !this.control.touched)
2013
2420
  return '';
2014
- return this.messages.resolve(this.control.errors, this.language, [
2015
- 'required',
2016
- 'email',
2017
- 'min',
2018
- 'max',
2019
- 'minlength',
2020
- 'maxlength',
2021
- 'pattern',
2022
- ]);
2421
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2023
2422
  }
2024
2423
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2025
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: `
@@ -2030,7 +2429,21 @@ class DateFieldComponent {
2030
2429
  [class.ngx-field--readonly]="readonly"
2031
2430
  [class.ngx-field--masked]="masked"
2032
2431
  >
2033
- <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>
2034
2447
  @if (masked) {
2035
2448
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2036
2449
  maskedText
@@ -2046,10 +2459,13 @@ class DateFieldComponent {
2046
2459
  [attr.data-testid]="'field-' + field.id + '-input'"
2047
2460
  type="date"
2048
2461
  [formControl]="$any(control)"
2462
+ [attr.aria-describedby]="describedBy()"
2049
2463
  [attr.disabled]="field.disabled ? true : null"
2050
2464
  />
2051
2465
  @if (errorMessage) {
2052
- <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>
2053
2469
  }
2054
2470
  }
2055
2471
  </div>
@@ -2070,7 +2486,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2070
2486
  [class.ngx-field--readonly]="readonly"
2071
2487
  [class.ngx-field--masked]="masked"
2072
2488
  >
2073
- <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>
2074
2504
  @if (masked) {
2075
2505
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2076
2506
  maskedText
@@ -2086,10 +2516,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2086
2516
  [attr.data-testid]="'field-' + field.id + '-input'"
2087
2517
  type="date"
2088
2518
  [formControl]="$any(control)"
2519
+ [attr.aria-describedby]="describedBy()"
2089
2520
  [attr.disabled]="field.disabled ? true : null"
2090
2521
  />
2091
2522
  @if (errorMessage) {
2092
- <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>
2093
2526
  }
2094
2527
  }
2095
2528
  </div>
@@ -2200,18 +2633,18 @@ class DateTimeFieldComponent {
2200
2633
  * "This field has an error", which made a documented, configurable feature work on three
2201
2634
  * of fifteen field types.
2202
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
+ }
2203
2644
  get errorMessage() {
2204
2645
  if (!this.control?.errors || !this.control.touched)
2205
2646
  return '';
2206
- return this.messages.resolve(this.control.errors, this.language, [
2207
- 'required',
2208
- 'email',
2209
- 'min',
2210
- 'max',
2211
- 'minlength',
2212
- 'maxlength',
2213
- 'pattern',
2214
- ]);
2647
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2215
2648
  }
2216
2649
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateTimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2217
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: `
@@ -2222,7 +2655,21 @@ class DateTimeFieldComponent {
2222
2655
  [class.ngx-field--readonly]="readonly"
2223
2656
  [class.ngx-field--masked]="masked"
2224
2657
  >
2225
- <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>
2226
2673
  @if (masked) {
2227
2674
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2228
2675
  maskedText
@@ -2240,9 +2687,12 @@ class DateTimeFieldComponent {
2240
2687
  [value]="inputValue"
2241
2688
  (change)="onInput($any($event.target).value)"
2242
2689
  [attr.disabled]="field.disabled ? true : null"
2690
+ [attr.aria-describedby]="describedBy()"
2243
2691
  />
2244
2692
  @if (errorMessage) {
2245
- <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>
2246
2696
  }
2247
2697
  }
2248
2698
  </div>
@@ -2263,7 +2713,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2263
2713
  [class.ngx-field--readonly]="readonly"
2264
2714
  [class.ngx-field--masked]="masked"
2265
2715
  >
2266
- <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>
2267
2731
  @if (masked) {
2268
2732
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2269
2733
  maskedText
@@ -2281,9 +2745,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2281
2745
  [value]="inputValue"
2282
2746
  (change)="onInput($any($event.target).value)"
2283
2747
  [attr.disabled]="field.disabled ? true : null"
2748
+ [attr.aria-describedby]="describedBy()"
2284
2749
  />
2285
2750
  @if (errorMessage) {
2286
- <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>
2287
2754
  }
2288
2755
  }
2289
2756
  </div>
@@ -2343,18 +2810,18 @@ class TimeFieldComponent {
2343
2810
  * "This field has an error", which made a documented, configurable feature work on three
2344
2811
  * of fifteen field types.
2345
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
+ }
2346
2821
  get errorMessage() {
2347
2822
  if (!this.control?.errors || !this.control.touched)
2348
2823
  return '';
2349
- return this.messages.resolve(this.control.errors, this.language, [
2350
- 'required',
2351
- 'email',
2352
- 'min',
2353
- 'max',
2354
- 'minlength',
2355
- 'maxlength',
2356
- 'pattern',
2357
- ]);
2824
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2358
2825
  }
2359
2826
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2360
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: `
@@ -2365,7 +2832,21 @@ class TimeFieldComponent {
2365
2832
  [class.ngx-field--readonly]="readonly"
2366
2833
  [class.ngx-field--masked]="masked"
2367
2834
  >
2368
- <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>
2369
2850
  @if (masked) {
2370
2851
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2371
2852
  maskedText
@@ -2381,10 +2862,13 @@ class TimeFieldComponent {
2381
2862
  [attr.data-testid]="'field-' + field.id + '-input'"
2382
2863
  type="time"
2383
2864
  [formControl]="$any(control)"
2865
+ [attr.aria-describedby]="describedBy()"
2384
2866
  [attr.disabled]="field.disabled ? true : null"
2385
2867
  />
2386
2868
  @if (errorMessage) {
2387
- <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>
2388
2872
  }
2389
2873
  }
2390
2874
  </div>
@@ -2405,7 +2889,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2405
2889
  [class.ngx-field--readonly]="readonly"
2406
2890
  [class.ngx-field--masked]="masked"
2407
2891
  >
2408
- <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>
2409
2907
  @if (masked) {
2410
2908
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2411
2909
  maskedText
@@ -2421,10 +2919,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2421
2919
  [attr.data-testid]="'field-' + field.id + '-input'"
2422
2920
  type="time"
2423
2921
  [formControl]="$any(control)"
2922
+ [attr.aria-describedby]="describedBy()"
2424
2923
  [attr.disabled]="field.disabled ? true : null"
2425
2924
  />
2426
2925
  @if (errorMessage) {
2427
- <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>
2428
2929
  }
2429
2930
  }
2430
2931
  </div>
@@ -2518,18 +3019,18 @@ class MonthYearFieldComponent {
2518
3019
  * "This field has an error", which made a documented, configurable feature work on three
2519
3020
  * of fifteen field types.
2520
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
+ }
2521
3030
  get errorMessage() {
2522
3031
  if (!this.control?.errors || !this.control.touched)
2523
3032
  return '';
2524
- return this.messages.resolve(this.control.errors, this.language, [
2525
- 'required',
2526
- 'email',
2527
- 'min',
2528
- 'max',
2529
- 'minlength',
2530
- 'maxlength',
2531
- 'pattern',
2532
- ]);
3033
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
2533
3034
  }
2534
3035
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MonthYearFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2535
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: `
@@ -2546,7 +3047,21 @@ class MonthYearFieldComponent {
2546
3047
  clicked. Each select then carries its own name below, because "Billing Cycle" alone
2547
3048
  does not say which half of the pair the screen reader has landed on.
2548
3049
  -->
2549
- <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>
2550
3065
  @if (masked) {
2551
3066
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2552
3067
  maskedText
@@ -2565,6 +3080,7 @@ class MonthYearFieldComponent {
2565
3080
  [value]="selectedMonth"
2566
3081
  (change)="onMonthChange($any($event.target).value)"
2567
3082
  [attr.disabled]="field.disabled ? true : null"
3083
+ [attr.aria-describedby]="describedBy()"
2568
3084
  >
2569
3085
  <option value="">{{ ui.text('month', language) }}</option>
2570
3086
  @for (m of months; track m.value) {
@@ -2579,6 +3095,7 @@ class MonthYearFieldComponent {
2579
3095
  [value]="selectedYear"
2580
3096
  (change)="onYearChange($any($event.target).value)"
2581
3097
  [attr.disabled]="field.disabled ? true : null"
3098
+ [attr.aria-describedby]="describedBy()"
2582
3099
  >
2583
3100
  <option value="">{{ ui.text('year', language) }}</option>
2584
3101
  @for (y of years; track y) {
@@ -2587,7 +3104,9 @@ class MonthYearFieldComponent {
2587
3104
  </select>
2588
3105
  </div>
2589
3106
  @if (errorMessage) {
2590
- <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>
2591
3110
  }
2592
3111
  }
2593
3112
  </div>
@@ -2614,7 +3133,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2614
3133
  clicked. Each select then carries its own name below, because "Billing Cycle" alone
2615
3134
  does not say which half of the pair the screen reader has landed on.
2616
3135
  -->
2617
- <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>
2618
3151
  @if (masked) {
2619
3152
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2620
3153
  maskedText
@@ -2633,6 +3166,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2633
3166
  [value]="selectedMonth"
2634
3167
  (change)="onMonthChange($any($event.target).value)"
2635
3168
  [attr.disabled]="field.disabled ? true : null"
3169
+ [attr.aria-describedby]="describedBy()"
2636
3170
  >
2637
3171
  <option value="">{{ ui.text('month', language) }}</option>
2638
3172
  @for (m of months; track m.value) {
@@ -2647,6 +3181,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2647
3181
  [value]="selectedYear"
2648
3182
  (change)="onYearChange($any($event.target).value)"
2649
3183
  [attr.disabled]="field.disabled ? true : null"
3184
+ [attr.aria-describedby]="describedBy()"
2650
3185
  >
2651
3186
  <option value="">{{ ui.text('year', language) }}</option>
2652
3187
  @for (y of years; track y) {
@@ -2655,7 +3190,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2655
3190
  </select>
2656
3191
  </div>
2657
3192
  @if (errorMessage) {
2658
- <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>
2659
3196
  }
2660
3197
  }
2661
3198
  </div>
@@ -2985,12 +3522,20 @@ class DropdownFieldComponent {
2985
3522
  return cached;
2986
3523
  return typeof value === 'object' ? resolveLabel(value, this.language) : String(value ?? '—');
2987
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
+ }
2988
3533
  get errorMessage() {
2989
3534
  if (!this.control || !this.control.errors || !this.control.touched)
2990
3535
  return '';
2991
3536
  // `requiredSelection` rather than `required`: "Please select an option" reads better on a
2992
3537
  // dropdown than "This field is required", and both stay independently overridable.
2993
- 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);
2994
3539
  }
2995
3540
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DropdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2996
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: `
@@ -3002,12 +3547,26 @@ class DropdownFieldComponent {
3002
3547
  [class.ngx-field--masked]="masked"
3003
3548
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
3004
3549
  >
3005
- <label class="ngx-field__label" [attr.for]="domId()">
3006
- {{ label }}
3007
- @if (field.validators?.required) {
3008
- <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>
3009
3568
  }
3010
- </label>
3569
+ </div>
3011
3570
  @if (masked) {
3012
3571
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3013
3572
  maskedText
@@ -3025,7 +3584,7 @@ class DropdownFieldComponent {
3025
3584
  [compareWith]="compareFn"
3026
3585
  [attr.disabled]="field.disabled ? true : null"
3027
3586
  [attr.aria-invalid]="control.invalid && control.touched"
3028
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
3587
+ [attr.aria-describedby]="describedBy()"
3029
3588
  >
3030
3589
  <option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
3031
3590
  @for (option of options(); track getOptLabel(option)) {
@@ -3065,12 +3624,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3065
3624
  [class.ngx-field--masked]="masked"
3066
3625
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
3067
3626
  >
3068
- <label class="ngx-field__label" [attr.for]="domId()">
3069
- {{ label }}
3070
- @if (field.validators?.required) {
3071
- <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>
3072
3645
  }
3073
- </label>
3646
+ </div>
3074
3647
  @if (masked) {
3075
3648
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3076
3649
  maskedText
@@ -3088,7 +3661,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3088
3661
  [compareWith]="compareFn"
3089
3662
  [attr.disabled]="field.disabled ? true : null"
3090
3663
  [attr.aria-invalid]="control.invalid && control.touched"
3091
- [attr.aria-describedby]="errorMessage ? domId('-error') : null"
3664
+ [attr.aria-describedby]="describedBy()"
3092
3665
  >
3093
3666
  <option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
3094
3667
  @for (option of options(); track getOptLabel(option)) {
@@ -3213,18 +3786,18 @@ class RadioFieldComponent {
3213
3786
  * "This field has an error", which made a documented, configurable feature work on three
3214
3787
  * of fifteen field types.
3215
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
+ }
3216
3797
  get errorMessage() {
3217
3798
  if (!this.control?.errors || !this.control.touched)
3218
3799
  return '';
3219
- return this.messages.resolve(this.control.errors, this.language, [
3220
- 'required',
3221
- 'email',
3222
- 'min',
3223
- 'max',
3224
- 'minlength',
3225
- 'maxlength',
3226
- 'pattern',
3227
- ]);
3800
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
3228
3801
  }
3229
3802
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RadioFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3230
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: `
@@ -3236,7 +3809,25 @@ class RadioFieldComponent {
3236
3809
  [class.ngx-field--masked]="masked"
3237
3810
  >
3238
3811
  <fieldset class="ngx-field__fieldset">
3239
- <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>
3240
3831
  @if (masked) {
3241
3832
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3242
3833
  maskedText
@@ -3253,6 +3844,7 @@ class RadioFieldComponent {
3253
3844
  type="radio"
3254
3845
  class="ngx-field__radio-input"
3255
3846
  [formControl]="$any(control)"
3847
+ [attr.aria-describedby]="describedBy()"
3256
3848
  [value]="getOptStoredVal(option)"
3257
3849
  [attr.disabled]="field.disabled ? true : null"
3258
3850
  />
@@ -3261,7 +3853,9 @@ class RadioFieldComponent {
3261
3853
  }
3262
3854
  </div>
3263
3855
  @if (errorMessage) {
3264
- <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>
3265
3859
  }
3266
3860
  }
3267
3861
  </fieldset>
@@ -3284,7 +3878,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3284
3878
  [class.ngx-field--masked]="masked"
3285
3879
  >
3286
3880
  <fieldset class="ngx-field__fieldset">
3287
- <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>
3288
3900
  @if (masked) {
3289
3901
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3290
3902
  maskedText
@@ -3301,6 +3913,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3301
3913
  type="radio"
3302
3914
  class="ngx-field__radio-input"
3303
3915
  [formControl]="$any(control)"
3916
+ [attr.aria-describedby]="describedBy()"
3304
3917
  [value]="getOptStoredVal(option)"
3305
3918
  [attr.disabled]="field.disabled ? true : null"
3306
3919
  />
@@ -3309,7 +3922,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3309
3922
  }
3310
3923
  </div>
3311
3924
  @if (errorMessage) {
3312
- <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>
3313
3928
  }
3314
3929
  }
3315
3930
  </fieldset>
@@ -3399,18 +4014,18 @@ class MultiSelectFieldComponent {
3399
4014
  * "This field has an error", which made a documented, configurable feature work on three
3400
4015
  * of fifteen field types.
3401
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
+ }
3402
4025
  get errorMessage() {
3403
4026
  if (!this.control?.errors || !this.control.touched)
3404
4027
  return '';
3405
- return this.messages.resolve(this.control.errors, this.language, [
3406
- 'required',
3407
- 'email',
3408
- 'min',
3409
- 'max',
3410
- 'minlength',
3411
- 'maxlength',
3412
- 'pattern',
3413
- ]);
4028
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
3414
4029
  }
3415
4030
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiSelectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3416
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: `
@@ -3421,7 +4036,21 @@ class MultiSelectFieldComponent {
3421
4036
  [class.ngx-field--readonly]="readonly"
3422
4037
  [class.ngx-field--masked]="masked"
3423
4038
  >
3424
- <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>
3425
4054
  @if (masked) {
3426
4055
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3427
4056
  maskedText
@@ -3436,6 +4065,7 @@ class MultiSelectFieldComponent {
3436
4065
  class="ngx-field__input"
3437
4066
  [attr.data-testid]="'field-' + field.id + '-input'"
3438
4067
  [formControl]="$any(control)"
4068
+ [attr.aria-describedby]="describedBy()"
3439
4069
  [compareWith]="compareFn"
3440
4070
  [attr.disabled]="field.disabled ? true : null"
3441
4071
  multiple
@@ -3450,7 +4080,9 @@ class MultiSelectFieldComponent {
3450
4080
  }
3451
4081
  </select>
3452
4082
  @if (errorMessage) {
3453
- <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>
3454
4086
  }
3455
4087
  }
3456
4088
  </div>
@@ -3471,7 +4103,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3471
4103
  [class.ngx-field--readonly]="readonly"
3472
4104
  [class.ngx-field--masked]="masked"
3473
4105
  >
3474
- <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>
3475
4121
  @if (masked) {
3476
4122
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3477
4123
  maskedText
@@ -3486,6 +4132,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3486
4132
  class="ngx-field__input"
3487
4133
  [attr.data-testid]="'field-' + field.id + '-input'"
3488
4134
  [formControl]="$any(control)"
4135
+ [attr.aria-describedby]="describedBy()"
3489
4136
  [compareWith]="compareFn"
3490
4137
  [attr.disabled]="field.disabled ? true : null"
3491
4138
  multiple
@@ -3500,7 +4147,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3500
4147
  }
3501
4148
  </select>
3502
4149
  @if (errorMessage) {
3503
- <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>
3504
4153
  }
3505
4154
  }
3506
4155
  </div>
@@ -3973,18 +4622,18 @@ class EntityRefFieldComponent {
3973
4622
  * "This field has an error", which made a documented, configurable feature work on three
3974
4623
  * of fifteen field types.
3975
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
+ }
3976
4633
  get errorMessage() {
3977
4634
  if (!this.control?.errors || !this.control.touched)
3978
4635
  return '';
3979
- return this.messages.resolve(this.control.errors, this.language, [
3980
- 'required',
3981
- 'email',
3982
- 'min',
3983
- 'max',
3984
- 'minlength',
3985
- 'maxlength',
3986
- 'pattern',
3987
- ]);
4636
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
3988
4637
  }
3989
4638
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityRefFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3990
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: `
@@ -3995,7 +4644,21 @@ class EntityRefFieldComponent {
3995
4644
  [class.ngx-field--readonly]="readonly"
3996
4645
  [class.ngx-field--masked]="masked"
3997
4646
  >
3998
- <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>
3999
4662
  @if (masked) {
4000
4663
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4001
4664
  maskedText
@@ -4015,6 +4678,7 @@ class EntityRefFieldComponent {
4015
4678
  [attr.data-testid]="'field-' + field.id + '-input'"
4016
4679
  [id]="domId()"
4017
4680
  [formControl]="$any(control)"
4681
+ [attr.aria-describedby]="describedBy()"
4018
4682
  [attr.disabled]="field.disabled ? true : null"
4019
4683
  >
4020
4684
  <option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
@@ -4029,7 +4693,9 @@ class EntityRefFieldComponent {
4029
4693
  }
4030
4694
  }
4031
4695
  @if (errorMessage) {
4032
- <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>
4033
4699
  }
4034
4700
  }
4035
4701
  </div>
@@ -4050,7 +4716,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4050
4716
  [class.ngx-field--readonly]="readonly"
4051
4717
  [class.ngx-field--masked]="masked"
4052
4718
  >
4053
- <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>
4054
4734
  @if (masked) {
4055
4735
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4056
4736
  maskedText
@@ -4070,6 +4750,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4070
4750
  [attr.data-testid]="'field-' + field.id + '-input'"
4071
4751
  [id]="domId()"
4072
4752
  [formControl]="$any(control)"
4753
+ [attr.aria-describedby]="describedBy()"
4073
4754
  [attr.disabled]="field.disabled ? true : null"
4074
4755
  >
4075
4756
  <option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
@@ -4084,7 +4765,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4084
4765
  }
4085
4766
  }
4086
4767
  @if (errorMessage) {
4087
- <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>
4088
4771
  }
4089
4772
  }
4090
4773
  </div>
@@ -4354,6 +5037,10 @@ class GroupFieldComponent {
4354
5037
  readonly = false;
4355
5038
  masked = false;
4356
5039
  userRoles = [];
5040
+ /** Author help text for the section as a whole. */
5041
+ get hint() {
5042
+ return resolveLabel(this.field?.hint, this.language);
5043
+ }
4357
5044
  get label() {
4358
5045
  return resolveLabel(this.field?.label, this.language);
4359
5046
  }
@@ -4363,11 +5050,27 @@ class GroupFieldComponent {
4363
5050
  }
4364
5051
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: GroupFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4365
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: `
4366
- <fieldset class="ngx-field ngx-field--group"
4367
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
4368
- <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>
4369
5070
  @if (masked) {
4370
- <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>
4371
5074
  } @else {
4372
5075
  <div class="ngx-field__group-children">
4373
5076
  @for (child of field.children ?? []; track child.id) {
@@ -4390,11 +5093,27 @@ class GroupFieldComponent {
4390
5093
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: GroupFieldComponent, decorators: [{
4391
5094
  type: Component,
4392
5095
  args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
4393
- <fieldset class="ngx-field ngx-field--group"
4394
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
4395
- <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>
4396
5113
  @if (masked) {
4397
- <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>
4398
5117
  } @else {
4399
5118
  <div class="ngx-field__group-children">
4400
5119
  @for (child of field.children ?? []; track child.id) {
@@ -4545,6 +5264,10 @@ class ArrayFieldComponent {
4545
5264
  userRoles = [];
4546
5265
  fb = inject(FormBuilder);
4547
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
+ }
4548
5271
  get label() {
4549
5272
  return resolveLabel(this.field?.label, this.language);
4550
5273
  }
@@ -4578,7 +5301,18 @@ class ArrayFieldComponent {
4578
5301
  [class.ngx-field--masked]="masked"
4579
5302
  >
4580
5303
  <div class="ngx-field__array-header">
4581
- <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>
4582
5316
  @if (!readonly && !masked) {
4583
5317
  <button
4584
5318
  type="button"
@@ -4590,7 +5324,6 @@ class ArrayFieldComponent {
4590
5324
  </button>
4591
5325
  }
4592
5326
  </div>
4593
-
4594
5327
  @if (masked) {
4595
5328
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4596
5329
  maskedText
@@ -4649,7 +5382,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4649
5382
  [class.ngx-field--masked]="masked"
4650
5383
  >
4651
5384
  <div class="ngx-field__array-header">
4652
- <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>
4653
5397
  @if (!readonly && !masked) {
4654
5398
  <button
4655
5399
  type="button"
@@ -4661,7 +5405,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4661
5405
  </button>
4662
5406
  }
4663
5407
  </div>
4664
-
4665
5408
  @if (masked) {
4666
5409
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4667
5410
  maskedText
@@ -4877,10 +5620,18 @@ class ImageFieldComponent {
4877
5620
  this.uploads.revokePreviewUrl(this.previewCache);
4878
5621
  }
4879
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
+ }
4880
5631
  get errorMessage() {
4881
5632
  if (!this.control?.errors || !this.control.touched)
4882
5633
  return '';
4883
- return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
5634
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
4884
5635
  }
4885
5636
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImageFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4886
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: `
@@ -4891,7 +5642,21 @@ class ImageFieldComponent {
4891
5642
  [class.ngx-field--readonly]="readonly"
4892
5643
  [class.ngx-field--masked]="masked"
4893
5644
  >
4894
- <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>
4895
5660
  @if (masked) {
4896
5661
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4897
5662
  maskedText
@@ -4920,6 +5685,7 @@ class ImageFieldComponent {
4920
5685
  accept="image/*"
4921
5686
  [disabled]="field.disabled || uploading()"
4922
5687
  (change)="onFileSelect($any($event.target).files[0])"
5688
+ [attr.aria-describedby]="describedBy()"
4923
5689
  />
4924
5690
  }
4925
5691
  </label>
@@ -4935,9 +5701,13 @@ class ImageFieldComponent {
4935
5701
  ever showed the first. A required file left unchosen said nothing, and a
4936
5702
  configured required message had no element to appear in. -->
4937
5703
  @if (uploadError() || errorMessage) {
4938
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
4939
- uploadError() || errorMessage
4940
- }}</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
+ >
4941
5711
  }
4942
5712
  }
4943
5713
  </div>
@@ -4958,7 +5728,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4958
5728
  [class.ngx-field--readonly]="readonly"
4959
5729
  [class.ngx-field--masked]="masked"
4960
5730
  >
4961
- <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>
4962
5746
  @if (masked) {
4963
5747
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4964
5748
  maskedText
@@ -4987,6 +5771,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4987
5771
  accept="image/*"
4988
5772
  [disabled]="field.disabled || uploading()"
4989
5773
  (change)="onFileSelect($any($event.target).files[0])"
5774
+ [attr.aria-describedby]="describedBy()"
4990
5775
  />
4991
5776
  }
4992
5777
  </label>
@@ -5002,9 +5787,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5002
5787
  ever showed the first. A required file left unchosen said nothing, and a
5003
5788
  configured required message had no element to appear in. -->
5004
5789
  @if (uploadError() || errorMessage) {
5005
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
5006
- uploadError() || errorMessage
5007
- }}</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
+ >
5008
5797
  }
5009
5798
  }
5010
5799
  </div>
@@ -5081,10 +5870,18 @@ class FileFieldComponent {
5081
5870
  this.control.markAsTouched();
5082
5871
  }
5083
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
+ }
5084
5881
  get errorMessage() {
5085
5882
  if (!this.control?.errors || !this.control.touched)
5086
5883
  return '';
5087
- return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
5884
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
5088
5885
  }
5089
5886
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FileFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5090
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: `
@@ -5095,7 +5892,21 @@ class FileFieldComponent {
5095
5892
  [class.ngx-field--readonly]="readonly"
5096
5893
  [class.ngx-field--masked]="masked"
5097
5894
  >
5098
- <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>
5099
5910
  @if (masked) {
5100
5911
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5101
5912
  maskedText
@@ -5134,6 +5945,7 @@ class FileFieldComponent {
5134
5945
  [id]="domId()"
5135
5946
  [disabled]="field.disabled || uploading()"
5136
5947
  (change)="onFileSelect($any($event.target).files[0])"
5948
+ [attr.aria-describedby]="describedBy()"
5137
5949
  />
5138
5950
  }
5139
5951
  </label>
@@ -5142,9 +5954,13 @@ class FileFieldComponent {
5142
5954
  ever showed the first. A required file left unchosen said nothing, and a
5143
5955
  configured required message had no element to appear in. -->
5144
5956
  @if (uploadError() || errorMessage) {
5145
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
5146
- uploadError() || errorMessage
5147
- }}</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
+ >
5148
5964
  }
5149
5965
  }
5150
5966
  </div>
@@ -5165,7 +5981,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5165
5981
  [class.ngx-field--readonly]="readonly"
5166
5982
  [class.ngx-field--masked]="masked"
5167
5983
  >
5168
- <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>
5169
5999
  @if (masked) {
5170
6000
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5171
6001
  maskedText
@@ -5204,6 +6034,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5204
6034
  [id]="domId()"
5205
6035
  [disabled]="field.disabled || uploading()"
5206
6036
  (change)="onFileSelect($any($event.target).files[0])"
6037
+ [attr.aria-describedby]="describedBy()"
5207
6038
  />
5208
6039
  }
5209
6040
  </label>
@@ -5212,9 +6043,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5212
6043
  ever showed the first. A required file left unchosen said nothing, and a
5213
6044
  configured required message had no element to appear in. -->
5214
6045
  @if (uploadError() || errorMessage) {
5215
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
5216
- uploadError() || errorMessage
5217
- }}</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
+ >
5218
6053
  }
5219
6054
  }
5220
6055
  </div>
@@ -5396,6 +6231,37 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5396
6231
  args: [CONFIG_SOURCE]
5397
6232
  }] }] });
5398
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
+ };
5399
6265
  /**
5400
6266
  * DynamicFormComponent — the main form component.
5401
6267
  * Renders a reactive form from EntityFormConfig with tab support, responsive 12-col grid,
@@ -5453,6 +6319,19 @@ class DynamicFormComponent {
5453
6319
  * disable the form. Used by the builder's live preview.
5454
6320
  */
5455
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';
5456
6335
  // ─── Outputs ──────────────────────────────────────────────────────────────
5457
6336
  formSubmit = new EventEmitter();
5458
6337
  formChange = new EventEmitter();
@@ -5471,9 +6350,15 @@ class DynamicFormComponent {
5471
6350
  hookRegistry = inject(HookRegistryService);
5472
6351
  rbacService = inject(RbacService);
5473
6352
  rulesEvaluation = inject(RulesEvaluationService);
6353
+ /** Resolves what is wrong with a field, for the error summary — see `resolveForField`. */
6354
+ messages = inject(ValidationMessagesService);
5474
6355
  entityRefSelection = inject(EntityRefSelectionService);
5475
6356
  commonModulesRegistry = inject(COMMON_MODULES_REGISTRY, { optional: true });
5476
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);
5477
6362
  Object = Object;
5478
6363
  // ─── Signals (local reactive state) ───────────────────────────────────────
5479
6364
  activeTab = signal('', ...(ngDevMode ? [{ debugName: "activeTab" }] : /* istanbul ignore next */ []));
@@ -5499,9 +6384,12 @@ class DynamicFormComponent {
5499
6384
  handleKeyboardEvent(event) {
5500
6385
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
5501
6386
  event.preventDefault();
5502
- 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)
5503
6392
  this.submit();
5504
- }
5505
6393
  }
5506
6394
  }
5507
6395
  // ─── Computed ─────────────────────────────────────────────────────────────
@@ -5652,10 +6540,35 @@ class DynamicFormComponent {
5652
6540
  get isValidating() {
5653
6541
  return this.form.pending;
5654
6542
  }
5655
- /** 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
+ */
5656
6548
  get submitBlocked() {
5657
6549
  return this.form.invalid || this.form.pending || this.hasRuleErrors;
5658
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
+ }
5659
6572
  ngOnInit() {
5660
6573
  this.selectionSub = this.entityRefSelection.selection$.subscribe(({ fieldId, option }) => {
5661
6574
  this.runAutoPatch(fieldId, option?.record);
@@ -5756,9 +6669,113 @@ class DynamicFormComponent {
5756
6669
  this.focusActivePanel();
5757
6670
  }
5758
6671
  getFieldSpan(field) {
5759
- const span = field.colSpan ?? 12;
6672
+ const span = field.colSpan ?? (this.layout === 'auto' ? (AUTO_COL_SPAN[field.type] ?? 12) : 12);
5760
6673
  return `span ${Math.min(12, Math.max(1, span))}`;
5761
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
+ }
5762
6779
  // ─── criticalField locking ────────────────────────────────────────────────
5763
6780
  /** A critical field stays read-only until the user deliberately unlocks it. */
5764
6781
  isFieldLocked(field) {
@@ -5791,8 +6808,15 @@ class DynamicFormComponent {
5791
6808
  next.add(field.id);
5792
6809
  this.unlockedFields.set(next);
5793
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
+ */
5794
6818
  resolveFieldLabel(field) {
5795
- return resolveLabel(field.label, this.language);
6819
+ return resolveLabel(field.label, this.language) || field.id;
5796
6820
  }
5797
6821
  /**
5798
6822
  * The changed critical fields as one comma-separated string.
@@ -5862,7 +6886,13 @@ class DynamicFormComponent {
5862
6886
  // never re-enable. submitBlocked reads form.pending and form.invalid, so this is what
5863
6887
  // keeps it honest.
5864
6888
  this.statusSub?.unsubscribe();
5865
- 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
+ });
5866
6896
  this.valueSub = changes$.subscribe(() => {
5867
6897
  const flattened = this.flattenFormValues();
5868
6898
  this.formValues.set(flattened);
@@ -6371,9 +7401,23 @@ class DynamicFormComponent {
6371
7401
  }
6372
7402
  async submit() {
6373
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
+ */
6374
7412
  this.markAllTouched();
7413
+ this.submitAttempted.set(true);
7414
+ const first = this.invalidFields()[0];
7415
+ if (first)
7416
+ this.jumpToField(first.field.id);
6375
7417
  return;
6376
7418
  }
7419
+ // Whatever the last attempt complained about is settled, so the summary goes away.
7420
+ this.submitAttempted.set(false);
6377
7421
  const rawData = this.extractRecord();
6378
7422
  let processedData = rawData;
6379
7423
  this.isSaving.set(true);
@@ -6411,14 +7455,16 @@ class DynamicFormComponent {
6411
7455
  this.formValues.set(values);
6412
7456
  this.previousValues = { ...values };
6413
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);
6414
7460
  this.formReset.emit();
6415
7461
  }
6416
7462
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6417
- 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 });
6418
7464
  }
6419
7465
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
6420
7466
  type: Component,
6421
- 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"] }]
6422
7468
  }], propDecorators: { config: [{
6423
7469
  type: Input
6424
7470
  }], rules: [{
@@ -6445,6 +7491,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6445
7491
  type: Input
6446
7492
  }], preview: [{
6447
7493
  type: Input
7494
+ }], layout: [{
7495
+ type: Input
6448
7496
  }], formSubmit: [{
6449
7497
  type: Output
6450
7498
  }], formChange: [{
@@ -6491,6 +7539,8 @@ class DynamicRecordFormComponent {
6491
7539
  isReadOnly = false;
6492
7540
  /** Specific field ids forced read-only while the rest of the record stays editable. */
6493
7541
  readOnlyFields = [];
7542
+ /** Forwarded verbatim to the hosted form — see `DynamicFormComponent.layout`. */
7543
+ layout = 'stack';
6494
7544
  /**
6495
7545
  * Open the record read-only, with a per-tab "Edit section" flow — one tab is edited and
6496
7546
  * validated at a time. This is the framework's `EntityRecordComponent` model and the
@@ -6517,8 +7567,6 @@ class DynamicRecordFormComponent {
6517
7567
  rulesEvaluation = inject(RulesEvaluationService);
6518
7568
  rbacService = inject(RbacService);
6519
7569
  hookRegistry = inject(HookRegistryService);
6520
- host = inject(ElementRef);
6521
- injector = inject(Injector);
6522
7570
  currentData = signal({}, ...(ngDevMode ? [{ debugName: "currentData" }] : /* istanbul ignore next */ []));
6523
7571
  originalBaseline = signal({}, ...(ngDevMode ? [{ debugName: "originalBaseline" }] : /* istanbul ignore next */ []));
6524
7572
  /**
@@ -6829,8 +7877,56 @@ class DynamicRecordFormComponent {
6829
7877
  return 'Record';
6830
7878
  return this.config.name ? resolveLabel(this.config.name, this.language) : this.config.entity;
6831
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
+ }
6832
7928
  get avatarLetter() {
6833
- return (this.recordTitle || 'R').charAt(0).toUpperCase();
7929
+ return (this.recordHeading || 'R').charAt(0).toUpperCase();
6834
7930
  }
6835
7931
  // ─── Header fields (isProfileImage / isHeaderToggle) ────────────────────────
6836
7932
  /** Every field in the config, at any depth. */
@@ -6974,61 +8070,24 @@ class DynamicRecordFormComponent {
6974
8070
  * field. The walk used to check top-level `fields` only, so every field in a sub-tab was
6975
8071
  * simply not found and the jump did nothing at all.
6976
8072
  */
6977
- locateField(fieldId) {
6978
- for (const tab of this.config?.tabs || []) {
6979
- if ((tab.fields || []).some(f => f.id === fieldId))
6980
- return { tabId: tab.id };
6981
- for (const child of tab.children || []) {
6982
- if ((child.fields || []).some(f => f.id === fieldId)) {
6983
- return { tabId: tab.id, subTabId: child.id };
6984
- }
6985
- }
6986
- }
6987
- return null;
6988
- }
6989
8073
  /**
6990
8074
  * Switches to the tab holding `fieldId`, then scrolls it into view and moves focus to it.
6991
8075
  *
6992
- * The wait for the new tab to render used to be a 50 ms `setTimeout`, which was wrong three
6993
- * ways: `document` is undefined on a server render, 50 ms is a guess that a large tab can
6994
- * outrun, and nothing cancelled the timer if the component went away first.
6995
- * `afterNextRender` fixes all three — it never runs on the server, it runs when the panel
6996
- * has actually rendered rather than when a guess expires, and it is tied to the injector so
6997
- * destroying the component cancels it. The query is scoped to this component's own element,
6998
- * 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.
6999
8081
  */
7000
8082
  jumpToField(fieldId) {
7001
- const location = this.locateField(fieldId);
7002
- if (!location)
7003
- return;
7004
- // The panel must not take the focus back: this jump is going to focus the field itself.
7005
- this.dynamicFormComp?.setActiveTab(location.tabId, { focusPanel: false });
7006
- // `setActiveTab` resets to a tab's first child, so the sub-tab has to be selected after
7007
- // it, not before. Without this the walk found sub-tab fields and then rendered the wrong
7008
- // panel, so the element the callback below looks for never existed.
7009
- if (location.subTabId) {
7010
- this.dynamicFormComp?.setActiveSubTab(location.subTabId, { focusPanel: false });
7011
- }
7012
- afterNextRender(() => {
7013
- // The field id comes from config, so it never goes into a selector string: no
7014
- // escaping to get wrong, and no need for `CSS.escape`, which jsdom and older
7015
- // browsers do not provide and whose absence throws silently inside a render hook.
7016
- const wanted = `field-container-${fieldId}`;
7017
- const el = Array.from(this.host.nativeElement.querySelectorAll('[id^="field-container-"]')).find(slot => slot.id === wanted);
7018
- if (!el)
7019
- return;
7020
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
7021
- // The slot carries tabindex="-1" so this actually moves focus. Without it the jump
7022
- // scrolled the field into view and left focus behind on the link.
7023
- el.focus();
7024
- }, { injector: this.injector });
8083
+ this.dynamicFormComp?.jumpToField(fieldId);
7025
8084
  }
7026
8085
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7027
- 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", 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]=\"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 (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: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 });
7028
8087
  }
7029
8088
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, decorators: [{
7030
8089
  type: Component,
7031
- 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 (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: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"] }]
7032
8091
  }], propDecorators: { config: [{
7033
8092
  type: Input
7034
8093
  }], rules: [{
@@ -7049,6 +8108,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
7049
8108
  type: Input
7050
8109
  }], readOnlyFields: [{
7051
8110
  type: Input
8111
+ }], layout: [{
8112
+ type: Input
7052
8113
  }], viewMode: [{
7053
8114
  type: Input
7054
8115
  }], formSubmit: [{
@@ -7072,5 +8133,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
7072
8133
  * Generated bundle index. Do not edit.
7073
8134
  */
7074
8135
 
7075
- 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 };
7076
8137
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map